23. Changelog¶
All notable changes to NNx are documented here. Format follows Keep a Changelog; versioning is roughly SemVer — pre-1.0, we allow behavior changes (typically bug fixes) without renaming public APIs.
This file intentionally keeps the standard Keep a Changelog heading format rather than the hierarchical numbering used by the rest of the documentation.
[Unreleased]¶
Added¶
- CUDA rows on the feature-composition matrix. The matrix now has a
cudaevidence profile beside the CPU one. FP16 AMP (TC-06), BF16 AMP (TC-10) and two-GPU NCCL DDP (TC-11) have tests, and a report from a GPU run verifies them. Until one runs, they stay unverified. CONTRIBUTING describes the opt-in GPU runner and a manual run (#420).
Fixed¶
- A DDP fit releases its wrapper when it ends. After
train(distributed=DDP()), including one that raised (a non-finite loss, say), theDistributedDataParallelwrapper and its hold on the process group are dropped. They no longer live on in an exception's traceback until an arbitrary garbage collection, possibly after the group is destroyed.nnx.distributed.shutdown()also collects such garbage before leaving the group (#478). - Invalid training settings fail before training starts.
NNTrainParamsandNNTrainerParamsrefuse a seed outside[0, 2**32), a non-stringdata_idand an unknownresume_from_checkpointtag. ACheckpointsmember there is stored as its tag string, so it no longer crashes the firstrun.yamlwrite after an epoch has trained. - Checkpoint reads and writes refuse a path-shaped
type.NNCheckpoint.load(run=..., type="../...")and the other checkpoint entry points raiseValueErrorinstead of reading or writing outside the run directory. - A resumed
linear_warmup_decayschedule on the epoch clock needs an explicitwarmup_steps. Its default (a tenth of each session's epochs) is not in the restored scheduler state, so a resume silently warmed up over a different length. - Continuous targets under a regression loss without a task fail with a hint. The default steps score classification. An MSE (or L1 / Smooth-L1 / Huber) model without
NNModelParams(task=TaskSpec.regression(...))whose targets are not whole numbers now gets that hint before its first update, and fromevaluate(), not a scikit-learn metrics error. - Imperative paradigm steps are refused as
objective=before a run is reserved. This covers KD, feature KD, Mixup, CutMix, MoE, SimCLR, DPO and text contrastive steps, as diffusion and JEPA already were. - A stopped epoch never reaches a full-history callback. With a history journal, a stop at an update boundary also discards the epoch's chunks that were already written.
- More late failures move earlier.
born_again_trainrefuses gradient accumulation before generation 0.Trainer.trainandNNModel.trainwithoutDDP()refuse a multi-rank process group.Trainer.trainrefuses update listeners andctx.stop_at_update, which it never delivers.set_seedrefuses a seed outside[0, 2**32)or a non-integer one.apply_ia3_torefuses to wrap a DoRA layer's base, and GGUF export refuses PEFT-adapted or prefix-tuned networks with a hint.decide_result(policy=...)checks the policy before calling the provider. - Smaller fixes:
widenupdatesFeedFwdNN.params.hidden_dims.DiffusionMLPandsample()run in the net's dtype.- bf16 modules work in the benchmark weights digest,
weight_histogramandactivation_map. VisUtils.classification_reportkeeps a named class absent from the data.load_provenanceraisesValueErrorfor JSON that is not an object.lr_finderstops at a non-finite loss.init_process_groupvalidatestimeout_secondslikeshutdown().- A dynamo-skipped forward under
on_failure="error"restores the model's buffers. random_block_maskvalidates its ranges.- Safetensors checkpoints are flushed to disk before the rename.
nnx.export_conformanceis bound onnnx.- A decision-benchmark metric with a value carries a
note(rows it does not count) instead of an "unavailable" reason; the CSV gains anotecolumn. decide_result(policy=...)refuses a policy that is not anAbstentionPolicy.- A history-journal run stopped in its first epoch loads.
- A damaged BEST checkpoint of the
runs/bestincumbent no longer fails a later run's final step; the pointer is re-elected. OneCycleLRkeeps each parameter group's own peak learning rate.- Mixup and CutMix draw from the torch RNG, so
seedcontrols them and a stateful resume continues the uninterrupted run. - Contrastive, KD, MoE and augmentation settings refuse NaN and infinity.
deepenrefuses annn.Sequentialsubclass or a container with hooks, parameters or buffers; on aFeedFwdNNit keeps eval mode.expand_embeddingkeeps the source'srequires_gradand mode;PromptTunerrefuses a prompt that fillsmax_seq_len.- GGUF export works for bf16 networks.
Trainer.trainbuildswriter_only(...)callbacks before its checks, so their monitors bind.weight_histogramandactivation_maprender large grids and validate their grid arguments;VisUtils.confusion_matrixrefuses labels that are not class indices.nnx.viz.attribute's figure shows the first sample of a 3-D attribution.- A prediction with zero rows accepts an empty
sample_idslist. - NumPy scalars in params fields are stored as plain floats, so
run.yamlserializes them. - A first run save that fails releases its reservation, so a retry is not refused.
Trainer.trainrefusesgrad_clip_norm/accumulate_grad_batchesthat atrainer_step_fnnever applies, and an objective run's optimizers that share a parameter.- A schedule whose optimizer was frozen in its first window advances without torch's misleading warning.
compare()of provenance manifests never equates a dotted key with a nested one.- A deeply nested
provenance.jsonis reported as malformed, not aRecursionError. split_linksrefuses non-integer edge categories;LinkPredictorrefuses zerowidth/outputs;link_metricsrefuses targets outside the declared domain.validate_decision_request_resultrefuses options on a Boolean question.- The imperative JEPA step refuses an empty context or target mask by name.
NNOptimParamswith an integermomentum(momentum=0) or betas trains; NumPy integers keep the Python-int run id.NNTrainParams/NNTrainerParamsflags must be bools.lr_findersweeps a negative loss and refuses non-finite arguments.FixedHeadProviderrefuses a regression loss without a task.LogitsChainBuilderrefuses custom processors aftertemperature(0.0), which would corrupt the greedy choice.link_metricsrefuses probabilities outside[0, 1](pass logits withfrom_logits=True).- A fully masked task batch with infinite outputs no longer turns the evaluated loss into NaN.
- OneCycle keeps a single scoped group's multiplier (a Trainer's strict optimizer).
- A Trainer objective run refuses
auto_step_schedulers=Falsewith schedulers (nothing would step them);NNTrainerParamschecks itsoptims/schedulerscontainers. - A custom eval step's
extra-only validation record survivesNNRun.load. NNTransformerParams.tie_embeddingsmust be a bool; recipes accept NumPy integerr/rank; LoRA, prefix and prompt sizes must be integers, checked before the base is frozen.NLIProvider.checkrefuses a bad request before any model call, so jobs and the benchmark spend no call on it.- The imperative diffusion and JEPA steps honour the model's batch adapter.
NoiseSchedulersvalidateTand the cosines;save_pretrainedrefuses quantized weights before writing.link_metricsrefuses NaN logits and non-finite categorical logits; a 1-Dattention_maskis anLMTaskError.- An objective run's whole-epoch training loss keeps each window's term weight and reduction (an annealed weight used the epoch's first).
CategoricalParamrefuses choices that compare equal (True,1,1.0), which optuna would collapse.NNParams.activation(s)andNNOptimParams.namemust be enum members (a string failed far from where it was set).link_metricsscores a-infcategory mask in a row with a finite maximum again.- A LoRA / low-rank recipe bundle resumes; only a train-end conversion (QAT) is refused.
- LoRA
alpha/dropoutaccept NumPy reals;ExperimentManifest.for_modelrefusestrain=besideconfig["train"]. - A run's notebook table escapes every value.
- Decision providers and
Capabilitiescheck their settings at construction (max_batch,ordinal,name, primitives). - The imperative JEPA step refuses a predictor outside
model.net(it was never updated). - Re-applying an adapter in a mixed LoRA / IA3 stack wraps nothing again.
FixedHeadProviderreports non-finite logits as aProviderFailure(newnnx.prediction.NonFiniteLogitsError) and a too-wide Bernoulli head as anInvalidDecisionRequest.NNSchedulerParamsrefuseswarmup_steps > total_steps;train_bpealways keeps an<unk>token, so unseen characters encode.- An optimizer built with a momentum of the wrong shape names the problem instead of a bare
AssertionError. - A window dropped as non-finite (
nonfinite="skip") no longer turns the whole-epoch training loss into NaN. - A warm-up longer than the run's default schedule length is refused when the scheduler is built.
predict/predict_probaaccept NumPy float64 input; evaluation records print their metric values.widen/deepenrefuse an adapted or factorized neighbour layer before changing anything; GGUF judges the quantization label before requiringgguf.- A
resume_epochs="planned"resume keeps the plan's provenance fingerprint. - Link tasks refuse a message graph with a repeated edge, and training negatives that cannot fit at
LinkTaskconstruction. FixedHeadProviderchecks label strings and refuses a non-2-D categorical output as a request error.- Library warnings (EarlyStopping,
predictover a shuffling loader) no longer crash in a REPL,python -cor a notebook. - An objective epoch whose every window is dropped as non-finite reports a NaN training loss, so EarlyStopping counts it; an objective whose terms are all detached is refused instead of training nothing.
load_lora_weights/load_ia3_weights/load_prefix_weightschange nothing when they refuse a wrong-shaped key.- The diffusion objective refuses a denoiser output of the wrong shape; bf16 / fp16 denoisers train in their own dtype.
decide_resultreturns aninvalid_decision_responseerror for an answer of the wrong count or question;question_from_staterefuses keys its kind does not take.- A numeric setting given as an integer too large for a float raises a
ValueErrornaming the field. - A failed eager re-run under
compile=restores the model's RNG and buffers before its error propagates. - The diffusion step and both JEPA paths refuse a prediction of the wrong shape instead of broadcasting it into the loss.
ExperimentManifest(config=None)reloads with its fingerprint, so the run keeps its provenance.- Construction refuses a plateau scheduler
factor >= 1,ModelCheckpointepochs that can never match, and non-stringNNTrainerParamsoptimizer / scheduler names;lr_finderrefuses a non-integernum_iterbefore the sweep. netron_export(launch=True)checks fornetronbefore writing the ONNX file.low_rank_factorize(andlow_ranktransform recipes) and offline-distillation scoring work on MPS models.- A refused task evaluation leaves the network's training modes as it found them.
MoELinear,ViTNNandViTBlockrefuse float or bool sizes and dropouts at construction.- Regression tasks, causal-LM training,
Standardizer, the NLI provider and the diffusion objective work on MPS (no float64 there); the compile snapshot restores the MPS generator. - Task and probability labels given as NumPy strings are stored as plain strings, so the run saves its
run.yaml. widenrefuses a container with its own forward or hooks instead of silently changing the model's output.generate(max_new_tokens=...),TopKFilter,Bootstrapand decision results validate their inputs at construction.- Near-greedy sampling (a small temperature) on half-precision logits picks the argmax instead of the first overflowed token.
- Offline distillation scores an fp64 student in its own dtype;
to_onnxtakes a float64 NumPy example. - NumPy strings and bools in model, metric, optimizer-factory and provenance configs are stored as plain values; decision-result ids too.
deepenrefuses a hooked target ReLU;widenlooks only at hooks on the path to its consumer.Trainerrefuses astop_at_updateset inon_optimizer_updateat that update, with advice that applies.NNPreferenceDatasetvalidates its lengths and pad id before encoding;NNCheckpoint.to_filerefuses an unknown format before creating a directory;split_linksrefuses a bare-stringcategories.- Knowledge distillation refuses single-logit heads (the soft loss was always 0) and a teacher of another shape; distillation, Mixup / CutMix and MoE steps threshold a BCE head's predictions instead of taking an argmax over one logit.
- Run comparison reads
idps.csvfloats exactly (the default parser was off by one ULP on about one value in five). - A refused or failing
on_train_beginafter a resume hands the model back unchanged (NNModel and Trainer). deepenrefuses aFeedFwdNN/ConvNNsubclass with its own forward; wildcard LoRA / DoRA / IA3 refuse aMultiheadAttentionprojection;ExperimentManifestkeeps labels given as a one-shot iterable.generate()validatesstop(a list of non-empty strings) andseed.- A weights-only resume, and a refusal at the first
on_epoch_begin, also hand the model back unchanged. - A
FeedFwdNNon graph split batches scores only the split's seed rows, as the graph nets do. - Distillation with
alpha=0trains a binary head again;born_again_trainrefuses a single-logit head before generation 0; distillation, MoE and Mixup / CutMix records read probability, soft and ignored targets as the default step does. - Wildcard PEFT refuses
TransformerEncoderLayer/TransformerDecoderLayerprojections; benchmarkRecord.executionmust be strict JSON; a registered run's summary shows its factory. NNModel.trainalso hands a resumed model back when the firston_epoch_beginraises; the progress bar names the value it shows (val.loss,train.error).- Transform recipes refuse a
Linearits parent reads directly, as the PEFT helpers do; distillation and MoE steps refuse regression targets before the update. - A
ModelCheckpointtag shaped like a training-state sidecar is no longer deleted by the next save;SGD_NESTEROVneedsmomentum > 0andNNSchedulerParams.kindmust be aSchedulersmember, both at construction. export_bundlerefuses thebestpointer (export the run by its own id); forward benchmarks digest modules with non-tensor state; benchmark records'executionmust survive the JSON round trip.- The loops warn when a parameter requires gradients but no optimizer owns it (unfrozen after a
param_groupsoptimizer left it out), instead of silently never training it. - Mixup / CutMix refuse regression targets before the update; categorical tasks score int32 / int16 targets; GGUF export refuses a factorized projection with a clear
TypeError. - Forward benchmarks digest torchao-quantized and packed-quantized weights exactly; validation messages show integer bounds exactly; a DDP step-count refusal on a resume's first epoch also rolls back.
PrefixTunerplaces prefixes by the projection's first floating tensor (a factorizedw_qkvworks) and refuses before freezing the model;embed_texts/export_to_faissreturn embeddings on the requesteddevicewithout moving the backbone.- QAT refuses a non-int
groupsizeand a run whosegroupsizedivides noLinear; a tiny temperature samples the argmax; KD / Mixup / CutMix / MoE refuse a sequence model's output. - The unowned-parameter warning names both causes and points at the caller's
train(); t-SNE projection refuses a non-categorical checkpoint before inference; benchmark digests cover sparse state; an empty held-out benchmark slice reports "no eligible rows". - Re-applying IA3 over an IA3 → DoRA stack is a no-op; an Ollama float parameter too large for a float is a
TypeError. NNTransformerParamsdropout fields refuse non-numbers likedropout_prob.- Training-loop and history warnings (unreported optimizer updates, an unfed monitor, a skipped plateau step, a resume without training state, worker RNG, component rollback and cleanup failures, lineage gaps) point at the caller's
train(), so a second run in a notebook warns again;kd_objectiverefuses a sequence model's output as the KD step does. NNModel.trainrefuses a registered net that still carries an unbaked pruning mask (its checkpoints could not rebuild it);magnitude_prune(bake=False)refuses aMultiheadAttentionprojection; KD withalpha=1ignores the hard labels (an all-ignored batch no longer fails).- A tiny temperature on all-negative logits samples the argmax, not token 0; the QAT "prepared nothing" error names a root
Linear; the DPObetadocstring states the right direction (largerbetakeeps the policy closer to the reference). CausalLMTaskrefuses an attention mask with padding before a real token (left padding): the model never receives the mask, so the NLL was not independent of padding. Benchmark weight digests include each tensor's dtype and shape (and a per-channel quantization axis).- A lazy-layer model's final LAST records the materialized optimizer topology, so a materialized module resumes from
last;eval_step_fnruns with the net in eval mode (modes restored after);to_onnxrefuses a mapping example with a clearTypeError. NNModel.trainrefuses a registered net whose weights a parametrization replaced (weight_norm,spectral_norm), since no checkpoint could reload it; distillation refuses a flat(N,)single-logit head (it was softmaxed across the batch);train_contrastiverefuseslr <= 0; a very negative mask value no longer makes a normal temperature greedy;predict,to_onnxandnetron_exporton a read-only (pandas) array no longer warn.from_pretrainedrefuses an explicitparams=,net_params=,tokenizer=ortransforms=instead of silently ignoring it; the parametrization refusal also covers a registered module's top-level parameters.- KD, Mixup / CutMix and MoE steps on an MLP over graph split batches read only the split's seed rows (they scored other splits' nodes);
born_again_trainrefuses a single-output registered or runtimenn.Sequentialbefore generation 0; a Jev provider with only an async client is refused before a synchronous job or benchmark call; declared metrics named like a record field log asmetrics/<name>; a failure to record a failed attempt never replaces the training error, even under-W error. from_pretrainedalso refuses a dict or list override that differs from the repo'sconfig.json; a Jev provider with only an async client is refused before a synchronous job or benchmark counts a call, even inside an event loop (a notebook cell);NNDatasetchecks the training transform's output is a tensor too; an explicitREDUCE_LR_ON_PLATEAUscheduler prints its plateau settings;VisUtils.multi_line_plotplaces its ticks on the x values.- A synchronous decision job or benchmark no longer blocks a provider that runs its own event loop (
asyncio.runinsidedecide); building anNNDatasetwith a random training transform no longer moves the global random streams;born_again_trainrefuses aTransformerNNbefore generation 0;NNPreferenceDatasetrefuses a pad id outside the vocabulary anddpo_train_step_factorya bool or negative one;widen,low_rank_factorizeandexpand_embeddingrefuse non-integer sizes. ExperimentPlan.validate()reports an unregistered optimizer factory and an update-clock scheduler budget a custom step cannot plan, so a search stops before its first trial instead of failing every trial;LinkPredictorcasts float64 node features to its parameters' dtype, asGraphClassifierdoes.inspect_bundlereports a built-in net'snet_params(its dimensions), so an untrusted bundle's architecture can be read before reconstruction;NNPreferenceDatasetchecks a pad id against the tokenizer's full length (added tokens included); a warning attributed to a plain script is no longer replaced by aDeprecationWarningunder-W error; a seededNNDatasetwith a random base transform keeps the global random streams;FeedFwdMoENNresolves its activations once, sotorch.compilecan trace it on older torch.- An explicit
train_step_fn=default_train_step(whatqat_train_step_factoryreturns) records the declared training metrics and supports a training-split monitor; under DDP a batch one rank cannot score stops every rank instead of hanging the others;VisUtils.confusion_matrixwithout names labels the classes present; calling aStandardizeron a DataFrame checks its columns;activation_mapreads a layer that returns a mapping; a bootstrap interval whose bounds meet while resamples vary says so. ExperimentPlanalso reports an update-clock scheduler budget over a training loader without a length (a factory's loader whenfit()calls it);TeacherDatasettakes read-only and reversed NumPy inputs.predict/predict_probatake a full-graph loader list (NNGraphDataset(sampler="full")) asevaluateanditer_predictdo;dpo_train_step_factoryrefuses a bool or non-numericbeta;NNPreferenceDatasetreports the tokenizer's full length (added tokens included) as itsinput_dim/output_dim.RankingTask.eval_step()scores an MPS model (its scores widen to float64 on the CPU).- An explicit
default_train_stepalso gets the checkpoint-reconstructibility guard and a search'supdates_per_trialcap, as the implicit default step does. widen,low_rank_factorizeandexpand_embeddingrefuse aweight_norm/spectral_normmodule (parametrization or legacy hook) or an unbaked prune mask instead of silently dropping it, reading a stale weight and unfreezing it.- Surgery refuses a subclass of
nn.Linear/nn.Embedding/nn.ReLUwith its ownforward(a fake-quantized QAT Linear, say) instead of rebuilding it as a plain module. - A resume that fails after restoring its state (a warnings-as-errors filter, say) hands the model and RNG back unchanged in a single-process fit too, through
NNModel.trainandTrainer.train. TopPFilter(1.0)(andgenerate(top_p=1.0)) keeps every token; float32 rounding had dropped some.- A resume whose first epoch fails before any batch (a loader
set_epoch, or the unowned-trainables warning raised as an error) hands the model and RNG back inTrainer.traintoo. validate_responserefuses avendor_scorefor a Choice or Boolean instead of dropping it;ChoiceResult/ScoreResultrefuse a distribution that is not(id, p)pairs withInvalidDecisionResponse.FeedFwdNN,FeedFwdMoENNandConvNNaccept non-contiguous inputs (a transposed NumPy array, achannels_lasttensor or net);MoELinearnames a malformed count withValueError.load_provenancerefuses JSON nested past 512 levels as "nested too deeply" on every Python version (3.14 parsed it and reported another error).drop_layerrefuses a NaN or infinite importance score;diffusion_train_step_factoryrefuses a non-NoiseSchedulebefore training.two_dim_tsne_checkpoint_logitsrefuses multi-output targets before the projection;multi_line_plotlegends every drawn dash style and checks label counts;weight_histogramcounts NaN / Inf values in each subplot title.- DoRA refuses an
nn.Linearsubclass with its ownforward(a QAT fake-quantized layer), at wrap time and when QAT prepare swaps the base later, instead of bypassing it; a PEFT wrap that fails part-way leaves every layer and its trainability unchanged. benchmark_forward/compare_compileno longer recurse without end on a tensor-subclass wrapper buffer;random_block_maskrefuses a non-finiteblock_aspectwithValueError; the docs builders' link validators read prose after an inline code span as prose too.- A
Trainerrun whose callbacks convert modules at train end (QAT) resumes statefully from its final LAST: the checkpoint records the optimizer topology from before the conversion, asNNModel.traindoes. nnx.viz.summary(model, input_size=...)builds its dummy input on the model's device instead of moving an MPS (or, on a CUDA host, a CPU) model;validate_recordrefuses a malformedweights_sha256.netron_exportcasts a NumPy float64 example to the net's dtype, asto_onnxdoes;summary(input_size=)builds its dummy in the model's floating dtype (a.double()model) and on its device even without parameters; benchmark coverage reasons count one fault across samples (messages no longer carry the sample id); the wiki and site builds no longer read prose after an inline code span (`x` [a]: ...) as a link reference definition.
0.4.1 (2026-10-11)¶
Bug Fixes¶
Documentation¶
- fix the site banner path and the re-audit's follow-ups (#472) (cb36cd1)
- repair audited documentation across README, site and wiki (#472) (812d6d1)
- repair the audited documentation across README, site and wiki (#472) (ff082fc)
- version-aware install guidance and the audit's follow-ups (#472) (b8e12f6)
0.4.0 (2026-10-09)¶
Added¶
- JEPA-trained encoders rebuild without the predictor from bundles and the Hub.
reconstruct_bundle(..., exclude_submodules=...)andNNModel.from_pretrained(..., exclude_submodules=...)leave out the named top-level submodules (the_jepa_predictorthe JEPA recipe registers undermodel.net) asfrom_checkpointdoes since #395, every other weight still strict (unlikestrict=False, a missing weight is refused). When the weights hold submodules the rebuilt net lacks, the error (a strict load's, or a registered module's schema check) names them and the option of the entry point called, with any names already passed — a bundle's no longer suggestsfrom_checkpoint, and the Hub's no longer surfaces torch's bare error. A bundle rebuilt without a submodule serves inference and refusesresume()(#441). ExperimentPlan.resuming(..., epochs=). A plan can ask forresume_epochs:epochs="planned"continues the source run's plan up ton_epochsin total,"additional"trainsn_epochsmore, andNone(default, unchanged) keeps the training parameters' own value.validate()checks it asNNTrainParamsdoes (resume.epochs) and reports a"planned"horizon with a"weights_only"mode or with no run to resume (including one set on the plan's training parameters) before any factory runs or run is reserved; at"additional"it stays out ofstate(), so run ids are unchanged (#442).- Exact resume to the planned horizon (
resume_epochs="planned") and verified resume points.NNTrainParams.resume_epochschooses whatn_epochsmeans on a resume:"additional"(default, unchanged) trainsn_epochsmore;"planned"continues the run's own plan up ton_epochsin total, carrying on the logical step counter,ctx.committed_updatesand the phase-checkpoint cadence, so on deterministic single-process training with in-process loaders a run split at epoch boundaries (and interrupted any number of times) equals the uninterrupted run bit for bit — with Adam or SGD,ReduceLROnPlateauacting; it needs a stateful source that records its plan — a weights-only resume, a pre-#394 checkpoint, another planned total or a finished plan is refused before anything is restored (aTraineralways resumes"additional"). Every checkpoint is now a resume point stamped by a manifest (<tag>.pt.manifest.json: generation ordinal, checkpoint id, logical position, SHA-256 per file), staged before the checkpoint is published and made live right after, with files fsynced and a Ctrl-C held for that window — a writer that dies inside it leaves a point verification reads from its staged manifest (verifying never writes); a resume verifies it first (NNCheckpoint.verify,NNCheckpoint.resume_point) and refuses a torn, mixed or corrupted point withResumePointErrornaming why. Checkpoints written before manifests load as before.run.resume_status(inmetadata.yaml, neverrun.yaml) addssource_generationandsource_checkpoint_id, and callbacks read the scheduler throughctx.scheduler(#394). - A public ViT network type:
Nets.VITandNNViTParams.NNViTParams(aNNParamssubclass) carries everyViTNNsetting —image_size,patch_size,in_channels,d_model,n_layers,n_heads,ffn_mult,attn_dropout,resid_dropout— validated at construction (positive integer sizes,image_size % patch_size == 0,d_model % n_heads == 0, and the encoder's base-field convention:input_dim = in_channels * image_size ** 2,output_dim = d_model, no hidden layers,dropout_prob = 0.0).Nets.VIT(params)buildsViTNN;NNRun.loadreturns the storedNNViTParamsandNNModel.from_checkpointrebuilds the encoder with its weights (throughNNParams.resolve_from_state, no caller-side swap).NNModel.from_checkpoint(..., exclude_submodules=("_jepa_predictor",))rebuilds a JEPA-trained encoder without the predictor the JEPA recipe registers undermodel.net(every other weight still loads strictly; an unknown name is refused). Task adapters refuseNets.VITup front (an encoder, not an(N, C)head). The architecture is always instate();in_channels,ffn_multand the dropouts are omitted at their defaults, so existing run ids are unchanged.docs/jepa.mdand example 16 build the encoder this way (#395). -
A bounded DDP teardown:
nnx.distributed.shutdown(timeout_seconds=60). The counterpart ofinit_process_group(): every rank meets at a teardown barrier, then the process group is destroyed, so no rank tears down its Gloo connections while a peer still uses them (a peer could otherwise abort with "terminate called without an active exception"). With Gloo, a rank that does not arrive in time makes the others raiseRuntimeErrorwithin the timeout instead of blocking in the call; the group is then left for process exit, which may still wait for the missing rank or the group's own timeout (with NCCL on older torch the group's timeout bounds the wait). Without a group, or after a successful call, it does nothing. The torchrun recipes (docs/quickstart.md,docs/architecture.md§3) andexamples/ddp_supervised.pyend with it (#419). -
A published feature-composition matrix.
docs/feature-composition.yaml(FEAT-039) registers which feature combinations are verified, unsupported or unverified. Each scenario across training controls, checkpoint/resume, model transformations, inference/export, decisions and typed results, and packaging (FEAT-010, -023, -025, -029, -030, -031, -033 and -037 each appear in at least one combination) records its device / dtype / dependency profile, expected boundary, pytest node ids and rationale.scripts/check_feature_composition.pyvalidates the schema and unique IDs, collects every node id (a stale reference fails naming its scenario), runs the scenarios with a phase-recording plugin (an import, a skip, an xfail or a setup / teardown failure never verifies), and rendersdocs/feature-composition.mdfrom that evidence report. A report from other sources or another profile renders unverified. A new CIfeature-compositionjob gates verified rows and keeps the report; the docs site and wiki render the same report (identical evidence digest). CUDA AMP and ONNX Runtime rows stay unverified on CPU (#256). -
Decision-model research pilot (experimental, no-go).
examples/decision_model_pilot.py(FEAT-023) scores(state, question, candidate description)triples with a shared encoder and scalar head registered throughregister_model_factory(no newNetsmember): Choice decisions softmax the candidates (cross-entropy) and Boolean ones take one logit (binary cross-entropy), both trained; Score is refused. A validated pre-execution manifest (licences, disjoint fit/select/held-out family splits, schemas, seeds, baselines, compute cap, stop rule, model identity, predeclared trade-off) comes first. The offline mechanics mode enforces the compute cap and produces a checkpoint reloaded through the registry, candidate-aligned replay records, trial and stop records, and a no-go report. The empirical mode writes its manifest and reports blocked: the resources are not supplied, and the empirical runs are outside this repository. Seedocs/decision-model-pilot.md(#262). -
Budgeted experiment search through Optuna (
nnx.search, extraoptuna).search(plan, space, apply=..., monitor=..., budget=SearchBudget(trials=, updates_per_trial=, deadline_seconds=), study_name=, storage=, sampler=, pruner=)runs a sequential Optuna study in which each trial's parameters become a freshExperimentPlanviaapply(the base plan is unchanged; fresh model, optimizer, loaders and callbacks; its own attempt id and run), scored by one validation monitor equal to the plan's own. The space (FloatParam,IntParam,CategoricalParam), budget, monitor and plan are validated before any trial, and a stored study with a different identity is refused. Completed, failed, pruned and orphaned trials all count against the budget. The update cap stops right after the N-th committed update, a deadline starts no trial once expired and stops a running one at its next boundary (recording the overrun), and a trial stopped mid-epoch keeps only its committed epochs. Pruning sees each validated epoch's value at step = epoch; missing or non-finite values fail. A trial's value is its run's monitor decision (the epoch its BEST checkpoint holds). A failed fit is recorded with its message and the search goes on; a configuration error inapplystops it. EveryTrialOutcomekeeps parameters, terminal reason, resources, monitor value and attempt / run / checkpoint links, andSearchResult.bestis the best completed trial. The training loop gainsctx.update_listenersandctx.stop_at_update(stop at an update boundary without committing the epoch in progress).import nnxnever imports Optuna. Seedocs/concepts.md§29 andexamples/search_offline.py(#266). -
Single-node data parallelism under
torchrun(nnx.distributed).model.train(params, distributed=DDP())trains over the process grouptorchrunstarted (nnx.distributed.init_process_group(); Gloo on CPU, NCCL with one GPU per rank) through PyTorch'sDistributedDataParallel, with every committed update equal to one process training on the ranks' union batch: gradients synchronize only at committed updates and are normalized by the global loss denominator (unequal valid counts included), an all-masked global window is skipped everywhere, and non-finite losses, step-count mismatches, preflight refusals and writer-side commit failures fail on every rank together instead of hanging.train_loader(dataset, batch_size, policy="pad"|"drop", seed=)partitions each epoch's seeded permutation evenly;validation_loadershards rows unpadded so every id is scored exactly once. Records are global and identical on every rank (monitors, early stopping, BEST and schedulers agree) and every rank returns the same run; only the writer rank holds the lease and writes history, checkpoints (canonical keys; world size, partitions and every rank's RNG for a same-world resume) and provenance. Callbacks declaredistributed = "all"or"writer";writer_only(factory)builds aTensorBoardCallback/WandbCallbackon the writer alone. FP32, default steps, one optimizer, one node, no batch normalization. Seedocs/architecture.md§3,docs/quickstart.md§3.10 andexamples/ddp_supervised.py(#265). -
Opt-in
torch.compileand bounded benchmarks (nnx.compilation,nnx.benchmarking).model.train(params, compile=CompileSpec(backend="inductor", mode=None, fullgraph=False, dynamic=None, on_failure="error"))runs the built-in step's FP32 forward (and the fit's validation) through a per-call compiled wrapper beside — never replacing —model.net, so checkpoints keep eager keys, optimizers own the eager parameters and export,summary, tying and later inference stay eager. Refused up front with a custom step or objective, a graph or custom net, reduced precision or a topology-changing callback (QAT); not for DDP or quantization, and no speedup is guaranteed.on_failure="eager"re-runs only the failing forward eagerly (never a partly applied update).CompileRecord(requested backend/options/policy/torch version; effectivecompiledwith full or partial capture,eagerwith its restart, orfailed) is onNNRun.compile, inmetadata.yaml, checkpoint training state andattempt.json, never in the run id.benchmark_forward/compare_compileseparate first-call compile cost from warmed latency, throughput and dispersion from identical weights;profile_forwardruns a finitetorch.profilerschedule into an explicit directory. Seedocs/concepts.md§28 andexamples/benchmark_compile.py(#264). -
An opt-in
Resultat fallible boundaries (nnx.result). FrozenOk/Errwithmap(only anOk),map_error(only anErr),bindandrecover(each flattening oneResultits callback returns; anything else raisesTypeError) andunwrap(raisingUnwrapErrorwith the typed error). Combinators never catch a callback's exception, and aResulthas no truth value —Ok(0),Ok(None)andOk(False)are successes. Three boundary wrappers convert exactly the exceptions they declare intoErr(BoundaryError(code, where, context, cause)):validate_decision_request_resultbuilds a typed question from plain data (InvalidDecisionRequest→invalid_decision_request, no provider call),decide_resultseparates a provider failure (provider_failure, orinvalid_decision_responsefor an answer that does not fit the question — a malformed Jev answer included — each with its request id;unsupported) from a successful abstention (anOkofSelectiveDecisions, probabilities and reasons kept), andinspect_bundle_resultdelegates to the safeinspect_bundle(artifact_missing,bundle_invalid; never unpickles). Every exception-style API is unchanged. Seedocs/decisions.md§10.1,docs/concepts.md§27 andexamples/result_boundaries.py(#263). -
Ordered logits pipelines (
nnx.generation.OrderedLogitsPipeline). Decoding stages that run exactly in their declared order, beside the canonically sortedLogitsChain(unchanged):OrderedLogitsPipeline.of(LogitsStage.temperature(0.5), LogitsStage.top_p(0.8))keeps duplicates (two temperature-0.5 stages equal one 0.25) and custom positions, andappend/prependreturn new pipelines over immutableLogitsStagespecs — a processor passed in is copied by value, and every run compiles fresh processors. Values are validated (finite, in domain) when a stage is made; a zero-temperature stage is greedy and terminal, so a stage after it raises instead of being reordered.generate(logits_pipeline=...)compiles the pipeline once per call on the cached and uncached paths, with token history,on_token,stopand seeded output equal to the explicit processor list; passing it withlogits_chainraises before the model is touched. Built-in pipelines round-trip throughstate()/from_state()({"version": 1, "stages": [...]}); aCustomStageis runtime-only (its state stays the caller's) unless its tag has aLogitsStageCodecregistered in-process — data never names code to import, and a pipeline never enters a checkpoint or a run identity. Seedocs/lm.md§5.1 andexamples/ordered_logits_pipeline.py(#254). -
Typed decisions on Jev models through the TypeSafe SDK (
nnx.decisions.JevProvider). A newjevextra (pip install "thekaveh-nnx[jev]", pinned and tested attypesafe-sdk>=0.7,<0.8) adaptsChoice,BooleanandScorequestions to the SDK'ssystem_onecall: one request per text holding every question (a Choice keyed by its options' descriptions, never their ids; a Score by its levels in order; a Boolean as anoul), answers realigned to each question's own order and validated, a Score's expected score invendor_score. Each result'srawis plain JSON — the model the service resolved (even for an alias request), the requested model, the request id and reported token usage, absent fields left absent, never clients, headers or credentials. A Choice's or Score'sconfidenceis recorded asprovider_confidence, Jev's own certainty rather than a probability of being correct; a Boolean gets none. The SDK'sRetryPolicyowns retries and the adapter sends each request once; what still fails becomesJevTimeout,JevAuthenticationError,JevRateLimited(withretry_after_ms),JevMalformedResponseorJevError, allProviderFailures keeping the SDK error as__cause__and the service'srequest_id, through a decision job (JobFailed.__cause__) or the benchmark (a record'sreason) too. Injected sync / async clients stay the caller's, and the provider never builds a second client beside one (a sync-only provider has no async methods, so a decision job runs it in its own worker thread); clients it builds itself close onwith(sync) /async with(both) exit, after an error or cancellation too. Each text is one request, so call budgets count provider calls, not requests — bound them withmax_batch.import nnxnever imports the SDK, and a provider that would need it raisesImportErrornaming the extra.scripts/smoke_jev_live.pyis an opt-in live smoke pinned tojev-1.13.0. Seedocs/decisions.md§6 andexamples/decision_jev.py(#220).
Changed¶
- A DDP resume verifies its resume point once. Under
distributed=DDP(), the writer rank verifies the point and shares the manifest or its refusal with the other ranks, which no longer hash it one after another under the tag's lock; a torn, mixed or corrupted point is still refused on every rank, with the sameResumePointErrorreason. Single-process resumes are unchanged (#440). - Resume points are hashed while they are written. Each checkpoint save computes its SHA-256 digests from the bytes it writes (large chunks on a helper thread overlapping the write) instead of reading the checkpoint and its training-state sidecar back; the manifest format and
NNCheckpoint.verifyare unchanged (#439). - The vision, graph and plotting stacks are extras (
thekaveh-nnx[vision],[graph],[plots];[domains]for all three). torchvision, torch_geometric and Plotly are no longer core dependencies, andimport nnxloads none of them: the core install trains, predicts, saves and reloads models with torch, NumPy, pandas and scikit-learn alone. Features that need one import it on use and, when it is missing, raise anImportErrornaming the extra —NNDataset(vision); the graph nets,NNGraphDataset,Nets.GRAPH_*,nnx.graph_tasksandnnx.link_tasks(graph);VisUtilsfigures,nnx.vizfigures andnnx.lr_finder(plots, which refuses before touching data, model or RNG state). AnImportErrorfrom inside an installed package keeps its own cause. With the extras installed, flat and deep names, class identity, signatures andNetsvalues are unchanged;nnx.__all__lists a domain name only when its extra is installed, sofrom nnx import *works on a core install. Without Plotly,NNRun's notebook repr keeps its table and drops the chart, andVisUtils.classification_reportstill returns a DataFrame. Thevizextra now pulls Plotly for its figures. Upgrading: installthekaveh-nnx[domains]to keep the previous dependency set.scripts/smoke_core_install.pychecks an installed wheel per profile, run by new CIinstalled-profilesand releaseartifact-profilesjobs over the minimum and current torch pairs; thefloor-depslane installs[domains](#249).
Fixed¶
- Resume points reach stable storage on macOS. Every resume-point flush (checkpoint, training state, manifests, directory) uses
fcntl(F_FULLFSYNC)where the platform has it — macOS'sfsynconly reaches the drive's cache, so a power loss could lose a point its manifest called complete — andos.fsyncelsewhere, as far as the drive honours the flush. A filesystem that does not support the flush does not fail a save; a real I/O error still does (#443). NNRun.loadreads a run's history back exactly.idps.csvis now parsed round-trip: pandas' default float parser misread about one value in five by one ULP, so a loaded run's losses, learning rates and metrics could differ from the trained run's in the last digit.nnx.search.search()andExperimentPlan.fit()refuse a multi-rank process group. Both train in a single process (a plan never passesdistributed=); launched undertorchrun, every rank ran its own study or fit into the same working directory. Inside an initializedtorch.distributedgroup of world size > 1 they now raiseRuntimeErrorbefore loading or creating a study, calling a factory, seeding or creating a run; a one-rank group behaves as before. Train one model across ranks withmodel.train(..., distributed=DDP())(#417).
0.3.0 (2026-10-04)¶
Not published to PyPI; its changes first ship in 0.4.0.
Upgrading from 0.2.3¶
Default training is unchanged: the same configuration gives the same run id, losses and weights, and runs, checkpoints and Hub folders written by 0.2.3 load and resume (unless their configuration is one this release refuses, below). Check these before upgrading:
- PyTorch 2.4 or newer. The declared floor is now
torch>=2.4/torchvision>=0.19(see Changed). Losses.NEGATIVE_LOG_LIKELIHOODtrains a different objective under the same run id. NLL now applieslog_softmaxbeforeNLLLoss(see Fixed), but a run id is a hash of the configuration, which did not change: re-running a 0.2.3 NLL configuration resolves to the 0.2.3 run's id, so it is refused as an existing run (or replaces it withoverwrite_existing=True), and resuming a 0.2.3 NLL run continues it with the corrected loss. Give the new run a distinctdata_idto keep both.EarlyStopping()picks its monitor. The defaultmonitor=Nonefollows the validation error, or the validation loss when the evaluator reports no error, so regression runs, which never stopped early before, now can;mode="max"needs an explicit monitor, and a monitor with nothing to read (for example no validation loader) warns once per run (see Fixed).- Stricter configuration. Configurations 0.2.3 accepted now raise
ValueErrorat construction: NaN, ±infinity or non-real hyperparameters, counts that are notint(an integral float such aspatience=8.0included),batch_sizesentries other than a positive integer orNone, and tabular features that are ±infinity or do not fitfeature_dtype(see Fixed). The same check applies when a saved run is loaded, so a 0.2.3 run whoserun.yamlrecorded such a value (patience: 8.0) no longer loads and is not aruns/bestcandidate. A few operations that went ahead silently now refuse withValueErrortoo:widenacross a Softmax or normalization boundary, andapply_lora_to/apply_dora_to/apply_ia3_toon aLinearregistered under more than one name. - A resume keeps the checkpoint's plateau rule or stops. Resuming with a
ReduceLROnPlateauscheduler whose direction, threshold mode or threshold differs from the checkpoint's (for example a changedNNSchedulerParams.threshold) now raisesValueErrorbefore anything is restored, where 0.2.3 silently kept the saved rule; resume with the original settings or passresume_mode="weights_only". - No stateful resume back on 0.2.3. Runs trained by this release write training-state format 4, which 0.2.3 refuses to resume (it reads 1–3). Checkpoints of runs that use only features 0.2.3 has still load there. A pickle checkpoint of a run using a new feature (a named monitor, a
TaskSpec, a registered model, a precision policy) refers to modules 0.2.3 lacks and does not load there; its safetensors checkpoint or Hub folder may (a named-monitor orTaskSpecrun's does).
Added¶
- Offline teacher-distribution datasets (
nnx.paradigms.offline_distillation). Distill a student from stored, permissioned teacher probabilities with no live teacher. ATeacherRecordcarries the sample and question ids, the ordered candidate ids and their probabilities (finite, nonnegative, summing to 1 within1e-6), the teacher and its revision, the schema digest, the probabilitysemantics("predictive","calibrated"or"empirical") and itsprovenance— thesourceand the declaredtraining_rights(recorded, not verified); a missing field fails validation, andwrite_teacher_records/read_teacher_recordskeep them as strict JSONL (nnx.teacher-record/1).TeacherDataset(records, inputs, candidates=..., schema_digest=...)joins records to inputs by sample id and aligns every distribution by candidate id, refusing a missing, unknown or duplicate candidate, another schema digest, a repeated sample or a sample without input when it is built — before any optimizer exists.dataset.objective(alpha=...)is an objective for the shared update engine:alpha · KL(teacher ‖ student) + (1 − alpha) · CE(student, label)at temperature 1 (no invented logits, noT²), both terms over one shared mask and denominator per microbatch so accumulation matches the full batch, partly labelled datasets and batches refused, each batch's candidate order checked against the objective's, teacher probabilities detached, and no backward, zero_grad, step, scaler or scheduler call of its own. Its identity (objective version, schema digest,alpha, candidate alignment) is checkpointed component state, so a stateful resume with any of them changed is refused before the first resumed update.evaluate_offlineanddataset.eval_step()report teacher agreement and imitation loss over every record and labelled accuracy / NLL over the labelled records, each with its own denominator (the validation record'slossis the imitation loss; monitorlabel_accuracy/label_nllby name for quality). Nothing reaches the network.kd_train_step_factory,feature_kd_train_step_factory,kd_objectiveandborn_again_trainkeep their logit / temperature contract and refuse these records, as do the default supervised steps. See Concepts §10.1, Typed decisions §9 andexamples/offline_teacher_distillation.py(#244). - Executable ONNX export conformance (
nnx.export_conformance). Structural validity and numerical parity are now separate, recorded claims. Two profiles —feedfwd-fp32-torchscriptandfeedfwd-fp32-dynamo— exportFeedFwdNN4-8-2 (ReLU, dropout 0, deterministic nontrivial FP32 weights) at batch 2 withdynamic_batch=Trueand execute it in ONNX Runtime on CPU: every raw logit at batches 1, 2, 3 and 7 must match native eval atrtol=1e-4,atol=1e-5, and a wrong feature width must be refused. Dependencies, export, check, load and parity have separate outcomes, with failures classified asmissing-dependency,export-error,state-changed(parameters, gradients or mixed train / eval modes changed by the export),checker-error,hash-mismatch,load-error,mismatch,input-contractorruntime-error. Each record (nnx.export-conformance/1, checked byvalidate_record) carries the source revision, config and weights digest, artifact SHA-256s (external data included), exporter options, requested and actual opset, dependency and runtime versions, provider, dtype, input cases and tolerances.verify_artifactsandexecutereject a changed, missing or unlisted file before anything is loaded.scripts/check_export_conformance.py --output conformance.jsonruns both profiles, with an exit code per failure class (10–18;--forceclears earlier artifacts). A new required CI job,export-conformance, does four things: it checks thatimport nnxworks without the runtime, and that the check then fails with exit code 10; it runs the profiles and their tests with zero skips allowed; and it uploads the JSON with its ONNX and external-data files. A new optional extra,onnx-runtime, has been added, anduv.lockhas been regenerated.examples/04_onnx_export.pylabels resultschecker-only(no parity claimed) orexecuted, whileexamples/12_quantize_int8.py, Netron and GGUF / Ollama stay structural or container-only. A new docs page,docs/export-conformance.md, renumbers the later pages (#255). - Leakage-aware link and edge prediction (
nnx.link_tasks).split_links(edge_index, num_nodes, val=, test=, seed=, directed=False, self_loops="bar", negatives=1)splits a static homogeneous graph's edges into a versioned manifest (LinkSplit,nnx.link-split/1): canonical edges (an undirected edge is(min, max), so duplicates and reverses cannot cross splits), fixed validation / test negatives drawn from the static complement (never a positive, a reverse, a duplicate or a barred self-loop; a request beyond capacity fails before sampling), the seed and the topology policy;replay(edge_index)reproduces membership.LinkTask(split, mode="binary" | "edge_label")builds candidate batches whose message graph is the training edges only — for every split — and checks each batch before any forward pass (a held-out positive in the message graph is named as a leak; a candidate's target and id must match the split, and training negatives never reuse fixed held-out ones). The objective trains ontrainbatches only, the training evaluator reads thevalsplit with every candidate exactly once, and the default train / evaluate / predict paths refuse link batches. Every training pass re-draws its negatives and shuffles its rows from(seed, epoch)—NNModel.trainandTrainer.trainnow calltrain_loader.set_epoch(epoch)before each epoch when the loader defines it (PyTorch'sDistributedSamplerconvention) — so a stateful resume draws what the uninterrupted run would have; a wrapper must forwardset_epochto the task's loader (materialised batches replay as they are); the negatives' seed is checkpointed and a resumed loader with another seed is refused. Edge-label mode keeps category order, declares whether labelled edges' existence is visible context and samples no negatives. Binary candidates give(K,)logits, categorical(K, C), aligned with ids, pairs and targets throughpredict(); evaluation materialises every candidate for exact AUROC / AP, scored from the float64 logits so a confident model's candidates are never tied by rounded probabilities (BCE-(ln .8 + ln .7 + ln .6 + ln .9) / 4, AUROC = AP = 1 for[.8, .3, .6, .1]/[1, 0, 1, 0]), and a one-class set reports them unavailable with the reason, soMonitorSpec("auroc")never elects it.link_predictor_spec(...)(GCN / GraphSAGE / GAT encoder with no activation after its last layer, so dot logits can be negative; dot or MLP decoder) is rebuilt on reload, and the manifest is component state"link.task", so a resume with another split fails before any update. Node-level nets and the graph-pooling adapter refuse link batches. Seeexamples/link_prediction_offline.py(#247). - Graph-level classification (
nnx.graph_tasks).GraphCollection(graphs, ids, targets=..., unlabeled=[...])holds whole PyG graphs with stable integer ids and graph-level targets, refusing duplicate ids, zero-node graphs, edges outside a graph and an unflagged missing target, and batches them in order (or shuffled with a seed) without ever splitting itself.GraphClassifier(encoder, pool="mean" | "sum", head)pools node rows into one row per graph — graph-local and node-order invariant (an identity encoder on constant features 1/2/3 over 2-, 5- and 9-node graphs gives mean[1, 2, 3], sum[2, 10, 27]) — and validates each batch (graph ids, ptr / batch-vector consistency, cross-graph edges, one target per graph) before the forward pass.graph_classifier_spec(...)is a registered recipe (GCN / GraphSAGE / GAT encoder, pool, linear head) rebuilt on reload. WithTaskSpec.categorical(n, ignore_index=IGNORE), loss and metrics are averaged over labeled graphs (NLLs[1, 2, 3]in batches of 2 then 1 give 2), so records, BEST selection and the run chart (now count-weighted for per-batch task records) use labeled-graph counts;predict_probareports graph ids as sample ids through shuffling and concatenation. Node-level nets,NNGraphDatasetover a multi-graph dataset andtwo_dim_tsne_checkpoint_logitsrefuse collection batches, andto_onnx/netron_exportrefuse a graph classifier before writing. Seeexamples/graph_classification_offline.py(#246). - Query-grouped ranking evaluation (
nnx.ranking).RankingTask(k=(10,), max_relevance=1, relevance_threshold=1, weighting="query" | "pair", candidate_sets="sampled" | "exhaustive", max_buffered=1_000_000, group_size=None)checks(features, query_ids, candidate_ids, relevance[, mask])batches (or mappings) before any update — mismatched lengths, mixed or duplicate candidate ids within a query, grades outside0..max_relevanceand bad masks are refused — with integer or string ids kept as host values, so candidate identity survives sorting, device moves and reloads.task.objective()is a FEAT-004 objective: the pairwise logistic loss over unequal-relevance pairs within a query (relevance[1, 0], scores[2, 0]→ 0.126928), weighted per query or per pair; ties and pairless queries contribute nothing.task.eval_step()buffers the validation stream undermax_buffered, joins each query's candidates whatever the batching or order, refuses a duplicate candidate, ranks by score with ties broken by candidate id and averagesMRR@k,Recall@kandNDCG@k(gain2^rel - 1, discountlog2(rank + 1)) over queries; a query with no relevant candidate is excluded and counted, andkabove a query's size is refused. The record (kind="ranking",count= scored queries) carries the metrics plusqueries,excluded_queries,candidates,candidates_per_queryandexhaustive_candidates, with no classification placeholder;task.metric_specs()registersranking.mrr/ranking.recall/ranking.ndcgsoMonitorSpec("ndcg_at_10")selects BEST, and the task configuration is checkpointed component state"ranking.task". A validation task record with no value (every target masked or every query excluded, or no error and no loss) no longer falls back to the training metrics for BEST selection or plateau scheduling.examples/ranking_offline.pymaps FAISS positions to stable document IDs for an ID-aligned evaluation;export_to_faissand the embeddings scope are unchanged (#252). - A causal language-model task with token-weighted evaluation (
nnx.lm_tasks).CausalLMTask(vocab_size, alignment="shift_inputs" | "pre_shifted", ignore_id=-100, pad_id=None, smoothing=0.0, tokenizer=None)declares how a batch becomes next-token targets — shifting token ids exactly once, or taking aligned(inputs, targets)windows unchanged — with an optional per-position loss mask (never an attention mask), and rejects bad shapes, dtypes, lengths and out-of-range non-ignored ids before any forward pass. The ignore id, the padding id and the loss mask select one set of valid positions for the objective's denominator, the NLL and the token accuracy.task.objective()is a FEAT-004 objective: the (smoothed) cross-entropy sum over valid positions with the valid count as its denominator, so gradient accumulation normalizes by the window's valid tokens; an all-masked microbatch contributes nothing and an all-masked window takes no optimizer or scaler step and clears its gradients (schedulers still advance once per epoch).task.eval_step()reports the epoch's unsmoothed NLL — total valid-token NLL over the valid-token count, independent of batching and padding — as the record'slossandmetrics["nll"], withperplexity(exp(NLL),+infon overflow) andtoken_accuracy; an all-masked epoch is unavailable — never elected BEST in place of a training loss, and skipped by a plateau scheduler (for any validation task record with no value: every target masked, or no error and no loss) — no classification placeholder is written, andMonitorSpec("nll")selects BEST on validation NLL. HuggingFace-style{"input_ids", "labels"}batches shift their labels with the ids. The task configuration (and an optional tokenizer identity) is checkpointed component state"lm.causal_task"with the valid tokens the run scored, and a resume with another configuration is refused before the first resumed update.examples/11_tinystories_lm.pynow trains with the task (no placeholder metrics, no hand-written accumulation) and ships the bounded offlinecausal_lm_task_workflow()(inline corpus,pre_shiftedwindows, train → validation → reload → generate). Seedocs/lm.md§6 (#251). - A reproducible, offline-first decision-provider benchmark (
nnx.decisions.benchmark).Samples carry a sample id, a typed question (its digest is the schema identity), the provider input, the true label, the task family (held-out families report apart), a grouping unit and the perturbation that produced them (permute_options,redescribe,add_distractors,add_none_of_the_above,add_context,rewrite_input).collect(provider, samples, provider_id=..., budget=Budget(max_calls, max_samples))is the live collector: explicit provider and budget, one attempt per batch,unsupportedrecords with the provider's own reason, and batches the budget cut short markedpartial_batch.Records keep the provider, model revision, prompt identity and execution metadata across a strict-JSONL round trip (write_records/read_records,nnx.decision-record/1).evaluate(samples, records, split=...)replays with no provider call and fits nothing: records join by sample id and question digest (never row position), duplicate / missing / extra / mismatched records become coverage counts, and each slice (in-family, held-out, family, perturbation) reports accuracy, macro-F1, NLL (exact by default,+infat a zero true-class probability), Brier, ECE with reliability bins and, given an abstention policy applied as given, selective coverage and risk — each with its denominator, or unavailable with a reason.Resourcesstaynullunless declared (measured numbers need hardware).bootstrap_intervalresamples grouping units with a recorded seed and flags degenerate samples. JSON, CSV and text exports agree;compare_reportsrefuses reports of another split or metric identity. Seedocs/decisions.md§8 andexamples/decision_benchmark_offline.py(#243). - Multi-seed summaries and paired comparisons (
nnx.comparison). A read-only utility for repeated runs: it loads no model, reads no checkpoint, elects noruns/bestpointer and writes nothing into a run. EachObservationdeclares itsMetric(name, direction, unit), evaluation split, checkpoint-selection rule, configuration, data and split identity, replicate key, completion status and the committed evaluation behind the value, with unknown facts leftNone.summarizepools only observations that agree on all of those and otherwise reports a stratifiedSummarynaming the differing fields (Summary.pooled()refuses withIncompatibleObservations); eachGroupSummarygives the count, mean and sample standard deviation (n - 1; unavailable forn = 1, both forn = 0) and keeps failed and non-finite attempts listed and counted. Duplicate attempts are rejected.compare(a, b, pairing=...)pairs two configurations by unique replicate keys whose meaning you declare, reports signedB - Adeltas (never flipped for a metric to minimize) and lists unmatched replicates; an optionalBootstrap(seed=...)records its unit, seed and method, and its interval is labelled seed variability.ComparisonReportexports a machine-readabletable()and a concisetext()carrying every run and attempt id, status, selection rule, unmatched pair and signed delta, unchanged by input order, and saves / reloads as strict JSON (nnx.comparison/1) with its results re-derived and checked.observations_from_runsreads saved runs (run.yaml,idps.csvor a history journal's committed records, and the provenance files, once each); a run with a parent carries the parent's identity (every ancestor's, with their declared data and split identities), the checkpoint tag, the resume mode and, when it is part of the procedure, the recorded start epoch in its configuration identity, so it pools only with runs descended the same way from identically configured parents; only recorded facts decide which parent attempt a run started from, and an unknown lineage (no record and no known order — say, a deleted parent with no recorded checkpoint) never pools; cycles and over-deep lineages of followed parents are refused in any listing order. Seedocs/concepts.md§23 andexamples/compare_seeds.py(#250). - Deferred decision jobs: batch independent questions, chain dependent ones (
nnx.decisions).DecisionJob.ask(question, id=...),DecisionJob.collect({key: job}),job.map(fn)andjob.then(fn)build an immutable job that calls no provider, runs no callback and draws no RNG;run(provider, state=..., limits=Limits(...))andawait arun(...)are the only effect boundaries, and they borrow the provider without closing it. Independent questions over one state share calls —ceil(count / cap)in collection order, keeping question ids and thecollectkey order — wherecapis the provider'smax_questionsfor adecide_manyprovider (1 for a provider with onlydecide, such asFixedHeadProvider), lowered byLimits.max_questions. Duplicate ids, unsupported questions and amax_tokenscap the provider cannot count are refused withInvalidJobbefore any call. Athencontinuation runs once, after its prerequisite succeeded, and returnsFollow(job, state=...): the answer mapped explicitly into follow-up state;max_depthandmax_requestsbound it (JobLimitExceeded). Runs are fail-fast:JobFailedcarries the completed outcomes and the failed and skipped question ids, nothing completed is re-run and the job never retries.arunruns up tomax_concurrencycalls at once; acancelevent stops scheduling and returns a"cancelled"JobResult, task cancellation cleans up only the job's own tasks,timeoutraisesJobTimeout, and requests already sent are reported assent, never rolled back. Outcomes distinguishanswered(rowsanswered/abstainedunder an abstention policy),failed,skippedandcancelled. A job ofask/collectpickles as plain data; one holdingmap/thenrefuses.FixedHeadProvider.check(question, inputs)exposes its pre-call checks.LogitsChainis unchanged. Seedocs/decisions.md§7 andexamples/decision_jobs.py(#245). - A local label-conditioned baseline:
nnx.decisions.NLIProvider. It scores texts against candidate descriptions supplied at inference — none exists when the provider is built, and no output head is resized — with a natural-language-inference model and tokenizer the caller supplies. NNx imports no NLI library, downloads nothing and never callsfrom_pretrained. AChoicesoftmaxes the entailment logits across its candidates (choice_scoring="entailment_softmax"; logitslog(3)/0→(0.75, 0.25)), with results keyed by the request's option ids in its order. ABooleansoftmaxes each text's own contradiction and entailment logits (boolean_scoring="entailment_vs_contradiction";0/log(4)→0.8), never normalizing texts together;Scoreis refused before any model call. The entailment / contradiction ids (ints, or label names resolved throughmodel.config.label2id), the hypothesis and Boolean templates, the scoring methods and the truncation policy are explicit and validated. Pairs go to the model's device in chunks ofpair_batch_size, in eval mode under no-grad, with every submodule's training flag restored on success and failure. Over-long pairs are measured first and either cut (truncation="only_first", reported inraw["truncated"]) or refused before any model call ("error"). Each result'srawrecords the pair logits andprovider.record()(templates, label ids, scoring, truncation, modelrevision,calibrated: False): the scores are zero-shot, not calibrated, and their transfer to a task stays empirical.embed_textsandexport_to_faisskeep their signatures. Seeexamples/decision_nli.py, which reports accuracy, macro-F1 and categorical NLL / Brier on supplied records with the split, revision and settings (#234). - Diffusion and I-JEPA objective adapters.
diffusion_objective(schedule, *, seed=None, noise_fn=None, nonfinite="fail")andjepa_objective(target_encoder, predictor, mask_fn, *, ema_momentum=0.996, nonfinite="fail")(alsoDiffusionObjective/JEPAObjective) describe DDPM noise prediction and I-JEPA latent prediction as objectives over the shared update engine, so both gain gradient accumulation (exact for uneven microbatches), mixed precision, clipping and the engine's non-finite policy;diffusion_train_step_factoryandjepa_train_step_factoryare unchanged. Each returns one normalized term ("noise_mse"/"latent_mse") — the squared-error sum over the valid elements over their count — and detached metrics with no classification fields, and never zeroes, back-propagates, scales or steps, so unequal target masks combine as(sum_1 + sum_2) / (count_1 + count_2). Records, update events, train monitors and BEST see that denominator-weighted loss. The JEPA EMA target advances once per committed update, from the updated online weights, never per microbatch or after a skipped window, through a newObjective.after_update(events)hook;Objective.check_run(model, *, optimizers, callbacks)refuses, before any run is reserved, a predictor that is not optimizer-owned exactly once, a target that is part ofmodel.netor optimizer-owned, a target without matching online parameters and topology-changing callbacks; and the imperative diffusion / JEPA steps passed asobjective=(or anObjectivepassed as the step function) are refused. Both adapters are checkpointable components — the diffusion objective's own generator (restarted for each run, so an equally seeded rerun draws the same noise; asamplepreview with its own generator never perturbs it) and spec; the JEPA spec, predictor reference, EMA target and counter — so two epochs equal one plus a stateful resume, and a duplicate predictor payload, another predictor reference or another spec is rejected before anything is restored. Seeexamples/08_diffusion_2d_mixture.py::objective_modeandexamples/16_ijepa_image_plumbing.py::objective_mode(#257). - Explicit FP32 / FP16 / BF16 precision policy (
nnx.precision).NNModelParams(precision=PrecisionPolicy("fp32" | "fp16" | "bf16", fallback="error" | "fp32"))replaces the Boolean-onlymixed_precision(CUDA FP16 or nothing): BF16 autocasts without a scaler on CPU and on CUDA devices with native bf16 support (compute capability 8.0+, not emulation), FP16 keeps theGradScaleron CUDA, and FP32 is the default. The policy resolves into aResolvedPrecisionagainst the model's device when the model is built and again before any run is reserved; a request the device cannot run raisesPrecisionUnsupportedErrorthere (noruns/artifact) unless the policy namesfallback="fp32", which trains in full precision and records why. Autocast wraps only the forward pass — the backward runs outside it, parameters stay float32 (nevermodel.half()) — and the update keeps its unscale → normalize → finite-check → clip → step order, scaler-free BF16 refusing a non-finite gradient.NNModel.train(the default step and objectives) andTrainer.train(objective=...)resolve one policy before callbacks and iteration and reuse it inevaluate()andpredict()/predict_proba(), whose outputs stay float32 (BF16 converts to a NumPy dtype). The built-in imperative paradigm steps (finalize_step) andTrainerstep functions run in full precision and refuse a reduced policy before any work is done (as doesborn_again_trainwith more than one generation, before generation 0 trains); a customtrain_step_fnreadsTrainStepContext.precision(a run with one records training as applied by the step, outside what the policy covers; an FP16 context without aGradScaleris refused rather than run unscaled), and an objectiveObjectiveContext.precision(withctx.full_precision(output)for float32 outputs before task preparation and records).kd_objectiveruns its frozen teacher in the teacher's own inference precision (its policy, else full precision), never in the student's — under the legacy flag on CUDA the teacher's forward therefore now runs in full precision rather than sharing the student's float16 autocast.ExperimentPlan.validate()reports an unsupported policy (model.precision) and a full-precision-only step under a reduced one (train_step_fn), andTrainer.trainresolves the policy before seeding or building optimizers. Under FP16 an update window is all-or-nothing across aTrainer's named optimizers (none steps when any one's gradients overflow, and an optimizer without gradients that window is left out of the scaler), and named optimizers sharing a parameter are refused before the run. An explicitPrecisionPolicy("fp32")normalizes to no policy, keeping the default's run id. The policy and the FP16 scaler state round-trip checkpoints; a stateful resume into a different effective precision fails before the model is touched, while aresume_mode="weights_only"warm start may switch.from_checkpointand Hub loading re-resolve on the destination device, never from saved metadata, and acceptprecision=to replace the policy.run.precision(also read back byNNRun.loadfrommetadata.yaml) records the requested and effective precision, the fallback reason, TF32 (reported separately, never set) and which NNx surfaces apply it (training where NNx runs the default step or an objective,evaluate()andpredict(); custom step code appliesctx.precisionitself) — notnnx.lr_finder,nnx.diffusion.samplingor generation.nnx.precision_support()reports each device's cells as verified, supported, unverified (hardware absent) or unsupported; CPU BF16 is verified by seeded classification and KD fixtures with accumulation within the predeclared FP32-reference tolerances (REFERENCE_TOLERANCES: FP16 5e-3 / 5e-4, BF16 5e-2 / 5e-3 for losses / weights). The legacymixed_precision=Truekeeps its meaning (FP16 inNNModel.trainon CUDA, plain FP32 elsewhere, no effect on evaluation, prediction orTrainer) and its run id; a policy contradicting it is refused, one agreeing with it (FP16) normalizes the flag away and keeps the policy's run id, and the precision field is omitted fromstate()when unset. AMP is decided by the policy, not byNNModel._build_grad_scaler: an FP16 run whose hook returns no scaler (or a disabled one), or a CUDA run that is not FP16 whose hook returns one, is refused before training.NNModelParamspickles written beforetask/precisionexisted still load. Seeexamples/01_synthetic_classification.py::precision_workflow(#248). - Explicit scheduler clock units (
NNSchedulerParams.clock).clock="epoch"(the default, omitted fromstate()so existing configurations keep their run id) steps a scheduler once per completed epoch, as before;clock="optimizer_update"(builder:.clock("optimizer_update")) steps it once per committed update of its optimizer — never per microbatch, an all-masked window or a skipped AMP step — so its horizons count updates (two epochs of five microbatches withaccumulate_grad_batches=2give six steps). Updates are reported explicitly: by the update engine for objectives, bydefault_train_stepafter each optimizer step it takes, byfinalize_step(so every built-in paradigm step), and by custom steps through the newTrainStepContext.report_update()/TrainerStepContext.report_update(name)(an epoch with no reported update warns); whoever callsoptimizer.step()reports it (default_train_stepandfinalize_stepreport their own steps, never one the AMP scaler skipped), and every report counts. Where NNx owns the windows and the loader has a length, the default horizon is the planned updates; otherwisetotal_steps/T_max/step_sizemust be set, and a one-cycle or warmup-decay schedule stops the run with an error at the first update pasttotal_steps(a cosine schedule that passes aT_maxthe run did not configure — a defaulted or checkpoint-restored one — warns). One-cycle and warm-up traces match native per-update stepping, accumulation and a short final window included. InTrainereach optimizer keeps its own count and schedule, andauto_step_schedulers=Falsedetaches update clocks too (the step function owns every scheduler step), including after a resume. Each update clock is a required checkpointed component (nnx.scheduler_clock[.<name>]: clock, owner, budget, and the schedule's position), so a stateful resume continues the schedule — reproducing the remaining LR sequence — and refuses a mismatched clock or budget (or a checkpoint from before component state, whose scheduler position counts epochs) before anything steps. A plateau scheduler refuses the update clock.LRMonitor.historykeeps one entry per epoch; the newLRMonitor.update_historyrecords(update, LR)per update. Seeexamples/scheduler_clocks.pyand theauto_step_schedulers=Falsescenario inexamples/09_gan_with_trainer.py(#236). - Replayable model transformation recipes (
nnx.transforms).TransformRecipe([lora("layers.0", r=4, alpha=8.0), low_rank("layers.1", rank=4)], materialization="in_place" | "fresh")records each operation's id, version, explicitnn.Lineartargets and config in order (copied from the caller's list, read-only, hashable and picklable; integer LoRAalpha/dropoutare stored as floats, soalpha=4andalpha=4.0share a run id).validate(model, optimizers=...)checks exactly whatmaterializewould, mutates nothing and raises oneRecipeErrornaming, by operation index and target, a missing target, a non-nn.Linearor aliased target, overlapping targets, a target inside another operation's subtree, an out-of-range rank, an unknown operation or version, an in-place model that unrecorded surgery already changed, and an optimizer already holding a target's parameters (build the optimizer after the recipe;check_optimizerrefuses one holding replaced parameters, the source of a fresh materialization or a frozen LoRA base without its adapter, and accepts one built afterwards over the trainable parameters, a subset, parameters outside the net, or unfrozen base tensors BitFit-style).materializechanges the model in place (the default, keeping its weights) or transforms a fresh, randomly initialized registered base, transactionally (a failure leaves the model and the global random streams as they were), and records the operations; a runtime-onlymodule=is refused. Every run checkpoint tag,ModelCheckpointsnapshots, bothNNCheckpointformats,save_pretrainedand run bundles (nnx.bundles) carry them —export_bundlerefuses any other recorded transform, such as a callback's own train-end operation, naming its index and id — andfrom_checkpoint/from_pretrainedreplay them on a fresh base before loading tensors — low-rank factors are allocated and loaded, never re-factorized — matching keys, trainability and predictions. A recipe model trains throughNNModel.trainandTrainer.train(any step function), its live topology checked against base-plus-recipe by name, shape and target configuration first; the recipe joins the run id andExperimentManifest.for_model(runs and manifests without one are unchanged), the finalLASTstores the weights once, and a resume from any tag needs the same recipe materialized. An unknown or malformed recorded transform names its index when replay fails; transformed weights that record no recipe (a raw state dict) get an error saying so fromfrom_checkpointand a strictfrom_pretrained(strict=Falsekeeps its partial load). Seeexamples/20_low_rank_surgery_ffn.py::recipe_reconstruction_workflowand Surgery §8 (#238). - Bounded training history with an append journal (
nnx.history).NNModel.train(..., history=HistoryJournal(retention, chunk_size))andTrainer.train(..., history=...)keep only the lastretentionrecords in memory (ctx.idps— a live, read-only window — and the returnedNNRun.idps) and append every record once toruns/<id>/history/— immutable JSONL chunks, a hash-chained index, per-epoch summary rows and a small manifest published atomically before LAST — instead of rewritingidps.csveach epoch, so memory stays bounded and bytes written grow linearly with the records. LAST stays the epoch commit marker: readers never show records past its epoch (an uncommitted tail left by a crash is ignored), a failed LAST republishes the previous manifest, and a corrupt, missing or altered committed chunk raisesHistoryCorruptionError.NNRun.loadreads only the committed tail; the notebook chart reads the per-epoch rows; BEST, theruns/bestelection and a resume read no records.iter_history(run_id, lineage=False)streams the committed history,export_history_csv(run_id, path, lineage=False)writes the legacyidps.csvlayout, andmigrate_history(run_id, spec=..., discard_uncommitted=False)explicitly moves a legacy CSV into a journal keeping the run id (read back before the CSV is removed; records past LAST are never dropped silently). A resumed run owns its own files and leaves its source byte-for-byte unchanged;lineage=Truefollows each run's recorded resume and yields the parent's committed records up to the checkpoint the child resumed from, then the child's, each epoch once (a run that started fresh, such as a born-again generation, keeps its own epochs).chunk_sizedefaults tomin(250, retention).LRMonitor.historykeeps the LRs of the lastretentionepochs (LRMonitor(bounded=False)keeps all); aCallbackneeding every record declareshistory_access = "full"(materialised aton_epoch_endandon_train_end), and a plain function callback is refused before training. A journal run is not saved under another runs root (its history would not follow). Opt-in: withouthistory=, runs, run ids andidps.csvare unchanged, and legacy and journal runs load side by side (NNRun.all).NNRungains a runtime-onlyhistoryfield (the journal directory, never instate()or the run id), andResumeStatusgainssource_epoch— the epoch of the checkpoint a session resumed from, written tometadata.yamlonly when known. Seeexamples/history_journal.pyand Concepts §4.5. - Portable, data-only run bundles (
nnx.bundles).export_bundle(run_id, destination, checkpoint="last", calibrators=...)publishes one of your run's checkpoints as a directory of data:bundle.json(format, version, generation id, every payload's SHA-256 and size) and ag-<generation>/directory withstate.json(schema-validated JSON with typed$tuple, integer-keyed$intdict, non-finite$floatand$tensorencodings),model.safetensors,training.safetensors(optimizer, scheduler, GradScaler, RNG and component state — a"resume"bundle; weights-only checkpoints such asModelCheckpointsnapshots give an"inference"bundle) andTemperatureCalibratorrecords, whose labels and weight fingerprints are checked against the model. NNx writes and reads the safetensors format itself, so bundles need no optional dependency.inspect_bundleandvalidate_bundlenever unpickle, call a factory or download; validation rejects missing, unlisted, swapped, corrupted, symlinked and out-of-root payloads, duplicate JSON keys, generation mismatches and unknown versions before any tensor is read.reconstruct_bundle(path, factories=..., components=...)rebuilds the model from caller-supplied factories (aModelSpec) and names every missing factory or component before any model is allocated; the rebuilt model predicts bit-for-bit like the source, andReconstructedBundle.resume(params)continues training from the bundle's state exactly like the original run (an"inference"bundle refuses before anything changes). A new generation is published by an atomic manifest swap, so an interrupted export leaves the previous bundle usable. Module extra state that is not a tensor, custom objects in training state and runtime-only modules are refused with no pickle fallback.NNCheckpoint.from_fileandNNModel.from_pretrainednow name a bundle (or bundle payload) instead of failing obscurely. Seedocs/hub.md§3, concepts §22,SECURITY.mdandexamples/run_bundle.py(#237). - Streaming prediction and mergeable metrics (
nnx.streaming).NNModel.iter_predict(loader, spec=None, *, rich=False)returns a context-managedPredictionStreamthat yields onePredictionBatch(logits, classes, sample_ids)per loader batch — or, with aProbabilitySpecorrich=Truefor a model with a task, aPredictionResult— in loader order. Concatenated (concatenate_predictions), the batches are exactlypredict()andpredict_proba(), including sample ids, graph seed-row slicing and categorical / multilabel / continuous decoding. Each batch runs in eval mode underno_gradwith every submodule's training mode restored before it is yielded or its error raised; closing the stream drops its references to the loader's iterator and the model, a closed stream ends iteration like a closed generator and cannot be iterated again (StreamClosedError), and the loader stays the caller's. The stream holds only the batch in flight.StreamingMetrics(metrics, semantics, labels=..., threshold=...)(orStreamingMetrics.for_task) accumulates declaredMetricSpecs withupdate/update_logits,mergeandfinalize: built-ins keep sums, counts and confusion counts, so memory does not grow with the samples and a merge is order-independent; a merge refuses different metric declarations (id, version, config, name), probability semantics, task labels or thresholds (MetricMergeError);finalize()returns a read-only, repeatableMetricSnapshot(empty and fully masked streams:count=0, every metric unavailable). A metric that needs every stored score (a rank metric such as AUROC) is refused in bounded mode unlessmaterialize=Trueasks for O(N) storage.streaming_eval_stepis an opt-ineval_step_fnthat builds the default validation record from counts and sums — the same task counts, weighted loss, metrics, monitor values and BEST choice — and refusesextra_metricsor a stored-score metric before any run is reserved (ExperimentPlan.validate()reports both). The built-inf1metric now accumulates confusion counts — the same values, with memory bounded by the number of classes.EvalStepContextgainsmetrics(the run's declared metrics, default()).predict(),predict_proba()andevaluate()are unchanged and eager. Seeexamples/prediction_stream.py(#242). - Immutable fluent experiment plans (
nnx.plans).ExperimentPlanholdsNNParams,NNModelParams,NNTrainParams, data, seed and callbacks; everywith_*method (with_epochs,with_seed,with_data,with_callbacks,with_callback_factories,resuming, ...) returns a new plan and leaves its source and siblings unchanged, nested collections included.validate()aggregates field-pathDiagnostics (PlanValidation,PlanError) while consuming no loader, calling no factory, building no model, reading no weights and writing no directory, and reports a resume that conflicts with parent lineage statically.probe(example_batch)seeds, builds a temporary model and runs a no-grad forward pass (and the training loss when the batch has a target), then restores the ambient RNG streams, cuDNN / deterministic-algorithm settings and seeding environment variables, even on failure.fit()returnsFitResult(model, run, metrics, attempt_id): it compiles to the existingNNModel(...).train(...)loop (seed, then the loader and callback factories once, then the model), so it matches an equally seeded imperative run on weights, history and callback order. Each fit is a distinct attempt whose id is folded into the run'ssalt, so repeated fits get distinct run directories and a plan never setsoverwrite_existing;resuming(run_id)records parent lineage and leaves the parent's artifacts intact.FitResult.metricsnames each split and its availability from the run's final epoch, with no second loader pass, and a train-only plan invents no validation. Loaders, callback instances, metric callables, step functions and components are borrowed and shared by every fit, while factories give fresh ones per fit.NNModel.train,set_seedand every params class are unchanged. Seeexamples/experiment_plan.py(#221). - Abstention policies and risk-coverage evaluation (
nnx.abstention).AbstentionPolicy(kind="max_probability" | "margin", threshold=..., labels=..., model_id=..., tuning_split_id=...)accepts a row when its top probability (or top-two margin; a tied top two gives 0) is at or above the threshold and abstains otherwise, with reasonbelow_probability_threshold/small_margin. Ties break by label order. Accepted and abstained outcomes keep the prediction, distribution, score and sample id; malformed probabilities raiseAbstentionErrorrather than abstaining, and inputs are never modified. A policy records its probability field (probabilitiesorcalibrated_probabilitieswith the calibrator id), threshold, label order, model id and tuning split, round-trips as JSON (nnx.abstention/1), and refuses input of another schema withAbstentionSchemaErrorbefore reading it.applytakes aCalibratedPrediction, a categoricalPredictionResultor arrays.decide(result, policy, model_id=...)keeps a typed decision a success when it abstains, preserving its distribution and raw-versus-calibrated identity.CoverageReportgivescoverage = accepted/totalandrisk = incorrect/accepted(risk unavailable with statusno_acceptedwhen nothing is accepted, never 0), keeping every original sample id;accepted_rowsfeeds hard-label reports only accepted rows;CoverageAccumulatoraggregates chunks to exactly the eager report.select_thresholdruns on a named validation split (never the test split), reports every candidate threshold with its counts, returnsno_feasible_policywhen nothing meets the empirical risk ceiling, andrisk_coverage_curveends in a curve-only accept-none endpoint that no policy can hold or load (a threshold of 1.0 still accepts a score of 1). Validation of probabilities, targets and sample ids, and JSON artifact handling, are now shared withnnx.calibration, andTemperatureCalibrator.transform(..., sample_ids=...)can name an array's rows (so calibrated chunks carry their own ids). Seeexamples/abstention_offline.py(#218). - Fitted classifier calibration (
nnx.calibration).fit_temperature(logits, targets, labels=..., model_id=..., split_id=..., train_split_id=..., test_split_id=...)fits one scalar temperature on a calibration split by minimizing its NLL in float64 over a copy of the categorical(N, C)logits (arrays, tensors or a categoricalPredictionResult). It rejects non-finite logits,C < 2, out-of-range targets and a calibration split id equal to the training or test id, and records that ids and loaders cannot prove the rows are disjoint (disjointness: "unverified"). A fit with no interior optimum (a separable split, constant logits, logits no better than uniform) or no convergence returns a failedCalibrationFitwith the reason, never a NaN calibrator. TheTemperatureCalibratorstores the temperature, the ordered labels, the model id, the fit config and the split ids, and serializes as JSON (nnx.calibration/1,to_json/save/load) that reloads with identical probabilities.transformrefuses logits with a reordered label list or another model id before reading them (CalibrationMismatchError), unless a namedoverriderecords the mismatch. It returns aCalibratedPredictionthat keeps the raw logits, raw and calibrated probabilities, decoded argmax, labels, sample ids and calibrator id as separate fields.report(..., split_id="test")shows held-out NLL, Brier, ECE and reliability bins before and after calibration, and itsoutcomeis"improved"only when neither NLL nor Brier got worse; the report also records any override.negative_log_likelihood(floor1e-12like the namednllmetric;epsilon=Noneis exact and gives+infat a zero true-class probability),brier_score(mean class-summed squared error) andreliability_bins(equal-width[lo, hi), the last closed at 1, empty bins with count 0) match the namednll/briermetrics.model_fingerprint(model)hashes the weights for use as a model id. The model,predict()and the run are never modified; the fitted temperature is unrelated to the sampling temperature ofgenerate. The module imports none of its consumers, and it shares the NLL / Brier terms with the named metrics and the softmax withpredict_proba. Seeexamples/calibration_offline.py(#217). - Train-fitted preprocessing and split transforms (
nnx.preprocessing).Standardizer.fit(source, rows=..., columns=...)fits per-column(x - mean) / scaleon an explicit training membership and reads no other row (DataFrames, tensors, arrays or any row-indexable source). The population statistics are float64, and a constant column gets scale 1. The fitted standardizer is frozen:transformreuses it for validation, test and inference under a declared schema. Named columns must be present and in the fitted order (extra columns are ignored), arrays must have the fitted width, and values must be numeric and finite; anything else raisesPreprocessingErrorbefore a model sees the data. Statistics, schema, output dtype and the fit-membership identity serialize as primitive JSON (to_json/save/load, formatnnx.preprocessing/1) and reload float64-exact; loading rejects bad lengths, non-finite values and non-positive scales.NNTabularDataset(standardize=True)fits on its training split only (random orsplit=manifest, same membership) and exposesds.standardizer;standardize=<fitted Standardizer>reuses one without refitting (its columns and dtype are checked before the split). A constant column keeps its exact value as the mean, and the fit copies only the training rows of the feature columns.NNModel.predicttakes model-ready features, so apply the reloaded standardizer once.SplitViewandNNDataset(train_transform=..., eval_transform=...)give training and evaluation independent input transforms over an untouched base dataset: targets, the basetransform, labels and order never change, and views are safe to read alternately and from DataLoader workers. Runtime callables are recorded by name as not reconstructible.ExperimentManifest.for_model(..., preprocessing=standardizer)records the fitted schema and fit membership. Defaults are unchanged, and datasetstate()gains a key only when these are used. Seeexamples/preprocessing_offline.py(#240). - Reproducible group, time and stratified splits (
nnx.data_splits).plan_split(ids, strategy=...)returns aSplitManifestof disjoint train / validation / test memberships by sample id, with the strategy, its parameters, the seed and the source identity.strategy="group"keeps every group in one split;"chronological"cuts atcutoffs=(validation_start, test_start)(a timestamp on a cutoff starts the later split, so ties never separate) and excludes the rows withingapbefore each cutoff;"stratified"keeps every class in every active split and, for a class with too few rows, raises (default) or keeps it in training (insufficient="train"). Plans depend only on ids and their groups / times / labels — never on row order or any global RNG (ids are ordered by SHA-256 of the seed;seed=Nonedraws and records one). A manifest is plain JSON (to_json/save/load, formatnnx.split/1) anddigest()is its split identity;ExperimentManifest(splits={...})accepts a manifest and records that digest.SplitManifest.resolve(ids, source=...)maps a plan back onto rows and raisesSplitErrorfor duplicate, missing or unexpected ids, ids of another type, a changed or uncheckable source identity, or a positional-only plan (plan_split(ids=None)requiressource=).NNTabularDataset(split=manifest, id_col=...)builds its loaders from a manifest instead ofrandom_split(id_colis required; only the planned rows are admitted and counted; empty validation / test →Noneloaders, empty train → error before any loader); withoutsplit=nothing changes. Seeexamples/split_replay.py(#239). - Versioned experiment provenance (
nnx.provenance).NNModel.train(..., provenance=manifest)andTrainer.train(..., provenance=manifest)record anExperimentManifest— the declared task, label order, model descriptor, data and split identities, objective version and config (ExperimentManifest.for_model(model, train=..., data=..., splits=..., objective=...)collects them without building a model or reading a loader). It is stored inruns/<id>/provenance.jsonwith its fingerprint: SHA-256 of canonical UTF-8 JSON (sorted keys, ordered arrays, format versionnnx.provenance/1inside the hashed document). A fresh attempt is recorded inattempt.json: attempt id,running→completed/failed/cancelled, last committed checkpoint (tag, epoch, generation), error, and on resume the parent attempt and checkpoint generation. Serialization rejects non-finite floats, sets, bytes and callables by path. Identities aredeclared(never reported as verified),digest(from the explicithash_file/hash_bytes) orunknown.compare(a, b)reads only provenance files and reports stable field paths asequal/different/missing/unknown/declared/verified, with absent provenance never equal. Both files are written atomically and never rewritten byNNRun.save;NNRun.loadexposes them asrun.provenance. A failed status write never masks the training error, and run ids are unchanged. Seeexamples/02_resume_training.py::provenance_mode(#241). - Provider-neutral typed decisions (
nnx.decisions). A new inference branch asks typed questions —Choice(2+Option(id, description)s with unique ids),BooleanandScore(2+ ordered levels) — and returns validated results:ChoiceResult/ScoreResultdistributions in the question's order (summing to 1 within1e-6) andBooleanResult.p_true, with the provider's own output kept inraw. Option ids are bookkeeping and never reachmodel_view();digest()changes when options are reordered or reworded and binds each result to its question.validate_responsereorders keyed provider output and rejects missing, duplicate, unknown or unlabeled ids, never renormalizing a malformed distribution.ScoreResult.expected_index(sum(i * p_i)) is documented as ordinal, with a vendor's own score kept invendor_score. Providers implement theDecisionProviderprotocol and declareCapabilities(primitives, modalities, dynamic labels, batch limits, inference / training / export), checked before any model call.FixedHeadProviderserves a trained classifier's head over its exact label space or a bijection onto it (Choice, ordinal Score, or, from one logit, the one Boolean the head was trained for —question=Boolean(...), any other Boolean is unsupported), reorders columns, rejects unseen labels before its model-call counter advances and restores model modes. The typed errorsUnsupportedCapability,InvalidDecisionRequest,InvalidDecisionResponseandProviderFailureare public. Importingnnx.decisionsstarts no backend and needs no hosted-SDK extra. New guidedocs/decisions.md(docs numbering shifts by one from API reference on); seeexamples/decision_fixed_head.py(#219). - Arbitrary modules and registered model factories (
nnx.models).NNModelParams.netnow also takes aModelSpec(id, version, config, seed)naming a factory registered withregister_model_factory(id, version, factory)(factory(config) -> nn.Module; plusunregister_model_factory/registered_model_factories/resolve_model_factory), andNNModel(module=m, params=NNModelParams(...))wraps a caller-owned module as is (model.net is m, never cloned or re-initialized), describing it with aRuntimeModuledescriptor (class name plus a fingerprint of the parameter layout and the module'srepr,reconstructible=False). Registered modules are built with Python, NumPy and torch seeded fromseed(the ambient RNG states restored) and rebuild through the registry from checkpoints (pickle and safetensors),from_pretrainedand the checkpoint t-SNE; an unregistered factory (MissingModelFactoryError) or a changed topology fails before any weight loads. Runtime modules train, checkpoint and resume normally; their checkpoints reportNNCheckpoint.reconstructible == False, reload withNNModel.from_checkpoint(ckpt, module=...), and portable saves (safetensors checkpoints,save_pretrained) fail before anything is written. Non-built-in modules see batches through a runtime-onlybatch_adapter—PositionalInputs(n_inputs),KeywordInputs(inputs, target), or the module's ownunpack_batch— andpredict()accepts a mapping of keyword inputs; the Mixup / CutMix, KD, feature-KD and MoE steps andkd_objectivesplit batches through the same adapter;nnx.viz.summary(model, batch=...)takes the same adapters and keeps every submodule's training mode.run.yamlomitsnetfor these descriptors andstr(run)/ the notebook table show the descriptor instead of built-in dimensions. Built-in nets, their serialization and run ids are unchanged. Thedevextra addsonnxruntimefor the ONNX parity test. Seeexamples/custom_module.pyandexamples/04_onnx_export.py::registered_module_variant(#216). - Objectives over one shared update engine (
nnx.objectives).NNModel.train(objective=...)andTrainer.train(objective=...)accept an objective in place of a step function. An objective returnsLossTerms per microbatch: a differentiable numerator plus an explicit denominator (reduction="mean") or none ("sum"); mixing the two within a window fails. The shared engine back-propagates each term into its own buffer and, per update window (accumulate_grad_batches, cut short at the epoch's end), divides every normalized term by its window-total denominator, so uneven microbatches, short windows and masks give exactly the full-batch update. It then runs autocast → unscale → clip → step and applies a declared non-finite policy (nonfinite="fail"raises before stepping,"skip"drops the window). It firesCallback.on_optimizer_update(ctx, event)once per committed optimizer update, never per microbatch, masked window or skipped step. Records carry a distinctupdate_count, also onctx.update_count.supervised_objective()andkd_objective(teacher, alpha, temperature)ship; the latter gives distillation gradient accumulation, AMP and clipping. One-microbatch windows take a single backward pass, zero-weight terms are never back-propagated, and parameters frozen or unfrozen between epochs train as with the default step. The update counters are checkpointed component state (nnx.update_engine) and continue across a stateful resume; an objective run's whole-epoch training loss uses the terms' own denominators. InTrainerthe engine steps every named optimizer once per committed update, each with its owngrad_clip_normand one event per optimizer; Trainer has no mixed-precision setting, so objectives run there in full precision (with a warning when the model asks for AMP on CUDA). Passing both a step function and an objective fails before anything runs. Imperative factories andfinalize_stepare unchanged. Seeexamples/10_knowledge_distillation.py::objective_mode(#214). - Named metric and monitor contracts (
nnx.monitors).NNTrainParams(metrics=[MetricSpec(...)], monitor=MonitorSpec(...))(also onNNTrainerParamsand its builder) is opt-in and serialized only when set, so existing run ids are unchanged. AMetricSpec(id, version, config, name)names a registered metric: built-inaccuracy/f1(decoded labels),nll/brier(probabilities: softmax for categorical, element-wise sigmoid for multilabel) andmae/mse(continuous outputs), or custom ones viaregister_metric(id, version, factory, input=, mode=). Values are accumulated over the full sample, never as batch means, and reported in the records'metrics, fromevaluate(loader, metrics=...)and from the default training step. Specs round-trip throughrun.yamlwithout code; an unregistered id or an input the model cannot provide fails before any run is reserved or metric code runs. AMonitorSpec(metric, split, mode, min_delta, on_missing, on_nonfinite)is the one improvement rule for BEST selection, a monitor-alignedReduceLROnPlateau(its direction and an absolutemin_deltathreshold) andEarlyStopping(monitor=MonitorSpec(...)): for NLL[0.8, 0.7, 0.695, 0.71]withmin_delta=0.01, all three see exactly two improvements. Runs with metrics or a monitor record a whole-epochtrain_summary(loss and error with full-epoch denominators, declared metrics over the full epoch) and the epoch'sselection(MonitorRecord: identity, direction, configuration, value, status, improved) on each epoch's last idp. Both persist throughidps.csvand pickle / safetensors checkpoint metadata, and are shown by the progress bar, TensorBoard / W&B (train_epoch/*,monitor/*), injected callbacks and the run chart. Named-monitor runs are never elected intoruns/best. Seeexamples/03_custom_metrics.py(#213). - Checkpointable component state and Trainer warm resume (
nnx.components). Anything that carries state across epochs registers as a named, versioned component (ComponentSpec(name, version, required)pluscomponent_state()/load_component_state(state, *, version), theStatefulComponentprotocol).NNModel.trainandTrainer.traindiscover components among callbacks and the step function (or take them throughcomponents=[...]), write their state into the same training-state sidecar generation as the checkpoint (format v4), and on resume validate every saved component before mutating anything — a missing, unknown or incompatible required component yields oneComponentRestoreErrorlisting every problem — then restore them transactionally after the callbacks' reset hooks, rolling back every restored component, the model and the RNG streams on failure. A component may add acheck_component_statehook whose problems join that report.EarlyStopping(early_stopping, optional; several default-named instances are numberedearly_stopping.2, … in callback order, and a changed monitor or mode is reported before restoring) resumes its best value, patience and selected monitor, so a split run stops at the uninterrupted epoch;jepa_train_step_factorynow returnsJEPATrainStep, whose requiredjepa.target_encodercomponent persists the EMA target encoder.Trainercheckpoints now carry every named optimizer's and scheduler's state (including the final post-callback LAST) and resume validates each optimizer likeNNModel.traindoes (type, registered factory, parameter topology, scheduler type and one-cycle horizon), andNNTrainerParamsgainsresume_from_run_id/resume_from_checkpoint/parent_run_id/resume_mode(NNTrainerParamsBuilder.resume_from(run_id, checkpoint, mode)), serialized only as parent lineage.resume_mode("auto"default,"stateful","weights_only"; also onNNTrainParams) chooses what is restored — a weights-only warm start needs no shared one-cycle horizon;run.resume_status(ResumeStatus, stored inmetadata.yaml) reports it.ModelCheckpointfiles declare themselves weights-only;resume_from_checkpointaccepts their<tag>_e<epoch>stem, and a stateful resume from one fails before mutating anything. Sidecars from earlier versions still resume, with components started fresh and a warning; default serialization and run ids are unchanged (#215). - Task adapters (
nnx.tasks,NNModelParams(task=...)). An opt-inTaskSpec—TaskSpec.categorical(n, ignore_index=...),TaskSpec.multilabel(n, threshold=...)orTaskSpec.regression(n)— makes the default training step,NNModel.evaluate(),predict()andpredict_proba()task-aware. The adapter validates each batch's output/target shape, dtype and range before any backward pass or optimizer update (TaskValidationError), masksignore_index/ NaN targets out of the loss and every metric alike (userextra_metricssee only the valid targets; masking needs an elementwise loss, evaluated at full shape sopos_weightstill applies), averages the loss over the valid targets with the default step's accumulation denominators (uneven batches and accumulated windows reduce exactly like one full batch; an all-masked window takes no update), and computes whole-loader metrics. Regression records carrymse/maeand leave accuracy / f1 / recall / precision anderrorasNone; multilabel records report subset accuracy plussubset_accuracy/element_accuracy; categorical mode matches the legacy loss and metrics.predict()returns continuous values for regression and threshold-decoded indicators for multilabel, andpredict_proba(X)may omit its spec (a regression result hasprobabilities=None,spec=None). The model's loss, output width and net type are checked at construction, andNNModel.train/Trainer.trainre-check the liveloss_fnbefore touching a loader.NNEvaluationDataPointgains optionalkind/count/status/metricsfields (an all-masked record hasstatus="empty"), which round-trip throughidps.csvand checkpoint metadata; TensorBoard / W&B emit the task metrics. The spec persists as a versionedtask:mapping inNNModelParams.state()— omitted without a task, so legacy serialization and run ids are unchanged. Seeexamples/regression_task.py. - Branching params builders (
copy()/from_params()).NNOptimParamsBuilder,NNSchedulerParamsBuilder,NNTransformerParamsBuilderandNNTrainerParamsBuildergaincopy(), which branches a complete or partial builder so setters on either never affect the other (nor values already built), and afrom_params(params)classmethod whosebuild()reproducesparamsexactly — every init field, the samestate()key order and omitted defaults, so the same run id. Configuration containers (param_groups, the per-layer lists, theoptims/schedulersmaps,extra_metrics) are copied one level deep while params values, loaders (any iterable), metric callables and registered-factory specs keep their identity; neither method iterates data, builds a model, advances an RNG or does I/O. Setters, last-variant-wins, modifier retention and.vocab()'s dimension reset are unchanged.from_paramsrejects subclasses and foreign values with aTypeErrornaming the type (anNNOptimFactoryParamsgoes straight toNNTrainerParamsBuilder.optimizer) and a field the builder cannot carry with aValueError.NNTrainerParamsBuilderalso gainsdata_id(...)andoverwrite_existing(...)setters, so sibling branches can train on distinctdata_ids. Seeexamples/builder_branching.py. - Probability-aware prediction (
NNModel.predict_proba,nnx.prediction). An opt-inpredict_proba(X, ProbabilitySpec(kind, class_axis, labels))returns aPredictionResultwith the rawlogits(identical topredict()),probabilities(categorical: softmax over the declared class axis, rows sum to 1; bernoulli: independent element-wise sigmoid, not normalized),decodedvalues (argmax classes, orlogit >= 0indicators), the spec's class axis and ordered labels, and per-rowsample_ids(input row index for arrays and tensors, iteration position for ordinary loaders — a shuffling loader warns — and the global node index, fromn_id/input_id, for graph seed rows). The task kind is declared, never inferred; a wrong label count or an invalid class axis (checked on a loader's first batch) or non-finite logits raisePredictionValidationError, and Bernoulli indicators refuseclass_indices. It accepts the same inputs aspredict()(arrays, tensors, tuples, loaders with graph seed-row slicing), restores every submodule's training mode, never touches parameters or gradients, andProbabilitySpec.state()/from_state()round-trip label order and class axis.prediction_from_logitsapplies the same rules to existing logits.predict()andPredictResultare unchanged;examples/01_synthetic_classification.pycompares both APIs on the reloaded BEST model (#211). Optims.ADAMWand an Adam-familyeps.Optims.ADAMW/NNOptimParams.builder().adamw(...)buildtorch.optim.AdamW(decoupled weight decay: the weights are scaled by1 - lr * weight_decaybefore the Adam update, whereADAM/ADAM_AMSGRADkeep their coupled L2 decay), matching native AdamW throughNNParamGroupSpecoverrides.NNOptimParams.eps(default1e-8, torch's) is validated as finite and positive, forwarded to the Adam family, rejected on SGD variants, and omitted fromstate()at its default, so every existing run id is unchanged (#235).- Registered optimizer factories (
nnx.optimizers).register_optimizer_factory(id, version, factory)plusNNOptimFactoryParams(factory=OptimizerFactorySpec(id, version, config), ...)let a run use an optimizer NNx does not ship without a fake built-in name.NNModel.train()andTrainer.train()now build every optimizer through one shared hook,nnx.build_optimizer, before the run is reserved: the factory is resolved in the registry (an unknown id/version fails up front, importing nothing), called once with exactly the parameter groups a built-in optimizer would receive (one shared ownership rule,resolve_param_groups; each group with explicitlr/weight_decay, strict Trainer ownership applied) and a read-only JSON-like config, and must return atorch.optim.Optimizerover exactly those parameters.run.yamlrecords only the spec (callables are rejected), soNNRun.load,str(run)and the notebook view reload offline without running factory code and name the optimizer byid@vN; warm resume additionally validates the recorded factory id, version, config (type-strict:1and1.0differ) and ordered named-parameter topology. New exampleexamples/optimizer_factories.py(#235).
Changed¶
NNEvaluationDataPoint'sf1/recall/accuracy/precisionare now optional (defaultNone), so regression and other non-classification records no longer have to fabricate them; records built with all four are unchanged.PredictionResult.probabilitiesand.specareOptionalfor continuous (regression-task) results, whosekindis"continuous".examples/26_custom_eval_step.pynow leaves the classification fieldsNonein its regression records and points plain regression atTaskSpec.- The declared PyTorch floor is now
torch>=2.4withtorchvision>=0.19(wastorch>=2.0/torchvision>=0.15). The advertised floor was never honest: the CUDA mixed-precision path constructstorch.amp.GradScaler("cuda"), which does not exist before 2.3, and on 2.3 the full test suite still fails in three library paths (torch.is_autocast_enabled(device_type)in the transformer forward, SDPA float masks for half/bf16/double queries, and the weights-only unpickling allowlist for the training-state sidecar). 2.4.1 / 0.19.1 is the oldest pair on which the whole suite passes; CI now runs afloor-depslane on exactly that pair (CPU, core dependencies only) next to the frozen all-extras lane, anddocs/external-contracts.mdpublishes the tested matrix. No fallback factory was added: the scaler protocol, checkpoint serialization and resume validation are unchanged. CUDA execution of AMP remains unverified in this change (no CUDA hardware in the local or CI environments) and is recorded as such.
Fixed¶
prediction_from_logits(..., sample_ids=...)refusesuint64ids above2**63 - 1withPredictionValidationErrorinstead of silently wrapping them to negativeint64ids, asnnx.calibrationandnnx.abstentionalready did (#218).NNTabularDatasetaccepts a DataFrame whose rows are reversed or strided (df.iloc[::-1]): it previously failed with torch's "At least one stride in the given numpy array is negative" during tensor conversion (#239).NNTransformerParams.from_statenow restores the inherited per-layeractivationsanddropout_probslists thatstate()records — those that differ from the net-wide scalar, e.g.[RELU, TANH]/[0.1, 0.3]. It dropped them toNone, so such a transformer config no longer equalled itself after a run-YAML, checkpoint or safetensors round trip. Lists that merely repeat the scalar are still omitted fromstate()and reload asNonewith an identicalstate()and run id (as for baseNNParams); states without those keys load unchanged.NNTransformerParams.builder().build()now supplies itsdropout_prob=0.0default under the set fields instead of beside them (withhidden_dims/activationleft to theNNParamsdefaults), sofrom_paramscan carry the inherited fields.NNModel.train()andTrainer.train()no longer print the absolute working directory in their completion line:Run saved to <cwd>/runs/<id>is now the portable display pathRun saved to runs/<id> (relative to the working directory), derived from the persistence root in one shared helper, so captured notebook output no longer embeds the executing worktree, temporary directory or container mount and is identical across machines apart from the run id. The line is documented as a display path, not artifact provenance; where and how runs are saved is unchanged (#190).RMSNormno longer truncates FP64 inputs to FP32: the mean of squares is computed inpromote_types(x.dtype, float32), so FP16/BF16 still reduce in FP32 while FP64 stays FP64. A doubleTransformerNN,ViTNNorJEPAPredictorno longer collapses large finite activations to zero (a1e30input used to normalise to0) and no longer carries FP32 rounding (~1e-8) through every norm. The cast back to the input dtype still precedes the weight multiply, so output dtypes, parameters,epsand checkpoint keys are unchanged. (#261).- Double-precision attention is correct again at ordinary sequence lengths: for FP64 queries the dropout-free SDPA path of
multi_head_causal_attentionnow upcasts its FP32 additive causal / prefix mask to FP64. CPUscaled_dot_product_attentionsilently mis-applies an FP32 mask to FP64 queries once the sequence reaches the kernel's vector width (8 tokens on AVX2, 16 on AVX512), so every attention layer of a doubleTransformerNNorPrefixTunerreturned wrong outputs (errors of order 1) from that length on; the full forward and incrementalforward_with_cachedecoding could therefore disagree, and greedygeneratewith and without the KV cache diverged on AVX2 machines. FP16/BF16/FP32 queries receive the mask unchanged (#261). - Run text files (
run.yaml,metadata.yaml,idps.csv, the history marker and thebest/POINTER.txtfallback) no longer leak.<name>.XXXXXXtemporaries: the atomic writer now owns its temp until the rename, so a failure while opening, writing, flushing or closing the temp, or in the rename, releases the descriptor, unlinks that temp and re-raises the original exception (a cleanup error, including a failing unlink, never masks it) while the destination keeps its previous bytes; a successful rename never touches the temp name again, and a bare filename keeps its temp in the destination directory instead of the system temp dir. A failed history-marker write now also releases the emptyruns/<id>/reservation, so the run can be retried instead of failing with "already exists" (#260). - Documentation now matches runtime behaviour for custom metrics,
EarlyStoppingand parameter groups: every custom-metric doc and docstring states the(y_true, y_pred)argument order (the Trainer builder said(y_pred, y_true)) and when metrics run (per batch in the default training step, once on the aggregate inevaluate(), at the custom step's discretion otherwise); the quickstart metric example no longer feeds class predictions toroc_auc_score;EarlyStopping'smodeis described as reversing the improvement direction for one of the four accepted loss/error monitors (accuracy/F1 monitors are not accepted); and the parameter-group examples inNNParamGroupSpec, the concepts guide and the quickstart put the specificencoder.*biasrule beforeencoder.*and*.bias, stating that the first matching rule wins and rules never merge (the old order left encoder biases with weight decay). The Trainer builder docs and the GAN example state that the step function owns optimizer updates while the Trainer steps schedulers per epoch unlessauto_step_schedulers=False. New executable doc-snippet and Trainer tests pin each contract, andexamples/03_custom_metrics.py(now executed by the smoke tests) checks an asymmetric metric against its validation labels and afterNNRun.load(#259). NNTabularDatasetnow rejects feature data it used to admit silently:±infin a selected feature column is rejected in source precision before thefeature_dtypecast (an int64 cast turned it into an INT64 extreme and a bool cast intoTrue); finite values outside an integerfeature_dtype's range (e.g.300→int8,1e30→int64), which the cast would wrap, are rejected in source precision too; and a finite value that overflows a narrower floating/complex dtype during conversion (e.g.1e5→float16) is rejected for features and for a floatingtarget_dtypetarget. Errors name the columns and the requested dtype (the NaN and non-finite-target diagnostics now name the dtypes too). Every admission check, including the classification contiguity check that used to run after the split, now runs beforerandom_split, so a rejection leaves the DataFrame and the global RNG untouched; unselected columns are still never inspected (#233).apply_lora_to,apply_dora_toandapply_ia3_tono longer split a shared layer: patterns now match every registration path (not only the first onenamed_modules()reports) and targets are grouped by registration slot. ALinearinside a shared container is wrapped once and every path sees the same wrapper (selecting it through its non-first path used to return 0); aLinearregistered under more than one name — which a wildcard used to wrap at one path only, leaving the other alias on the raw layer — now raisesValueErrornaming every alias. The whole matched set is validated before any wrapper is built, so a rejected call changes no module, weight,requires_gradflag or parameter-group membership, and an unselected alias does not block independent targets. Tied tensors between distinct modules are outside this check (#224).- PEFT wrappers now inherit the train/eval mode of what they wrap at construction:
LoRALinear/DoRALinear(directly or viaapply_lora_to/apply_dora_to) copy the wrappedLinear'strainingflag onto the wrapper and itslora_dropout,IA3Linearonto the wrapper, andPromptTuner/PrefixTunerthe wrappedTransformerNN's flag onto the tuner. Injecting a nonzero-dropout adapter into aneval()model used to produce a train-mode wrapper whose dropout made inference nondeterministic once the adapter was nonzero, and a mixed-mode parent had every replaced child flattened to train mode. The surrounding model is never switched, modes are not serialized, and a latermodel.train()still activates the adapter dropout (#223). EarlyStopping()'s default monitor no longer goes silently inactive on regression runs:monitornow defaults toNone, which selectsval_edp.errorwhen the first validated epoch reports a finite error and otherwiseval_edp.loss(evaluators that leaveerrorasNone), then keeps that field for the wholetrain()call and re-selects on the next call (exposed asselected_monitor). Classification runs keep tracking the validation error, and the default still never reads training metrics;mode="max"now requires an explicit monitor. An explicitmonitorstill compares exactly the named field with no fallback, but a missing field or data point (for example no validation loader) is no longer silent: the epoch is not counted and oneRuntimeWarningper run, attributed to the calling code and repeated for every run, names the monitor and what is absent. A NaN/±inf value no longer becomes the best (which made every later comparison fail); it counts as an epoch without improvement (#189).NNGraphDatasetnow represents an emptyval_mask/test_maskas an absent split in both sampler modes: that loader isNone(the optional-loader contractNNDatasetBase,NNModel.trainandTrainer.trainalready honour, so validation is skipped rather than run over zero seed rows), the resolved size is0andstate()reports"0"; an explicit positive request for an empty neighbor split also yieldsNone. Previously full mode built a zero-node batch that made training fail withevaluate() loader produced zero samples, and neighbor mode failed insideNeighborLoaderwithbatch_size=0. An emptytrain_masknow raises aValueErrornaming it at construction instead of producing a zero-size training loader. Nonempty splits, the full-graph permutation /input_idseed-row scoring andsampler="full"'s rejection of explicit sizes are unchanged.- Dataset
batch_sizestuples now treatNoneas the only automatic full-split sentinel inNNDataset,NNTabularDataset,NNPreferenceDatasetandNNGraphDataset: zero andFalse— which truthiness resolution silently turned into a full-split request, changing memory demand and optimizer cadence — as well as negatives, floats (2.0included), strings and NaN raise aValueErrornamingbatch_sizes[i] (split), and a list, a bare value or a tuple of the wrong length raises with the 3-tuple contract instead of anIndexErrorafter the dataset was built. Validation runs before the dataset factory,tokenizer.encodeor any RNG-consuming split; positive sizes are kept verbatim (a size larger than the split yields one smaller batch, never a clipped metadata value); NumPy integers are normalized toint(a rawnumpy.int64used to be rejected byDataLoader). The empty-split conventions (placeholder1plus aNoneloader) andsampler="full"'s rejection of explicit sizes are unchanged. The four wrappers' docstrings, the quickstart and the DPO guide now state that an omitted /Noneslot is one batch holding the complete split — one optimizer step per epoch for the default train loader — and show an explicit mini-batch configuration; the runtime default itself is unchanged (#188). - Public configuration counts are now validated as integers at their boundary:
NNParams.input_dim/output_dim/hidden_dimsentries /n_heads,NNTransformerParams.vocab_size/n_layers/d_model/max_seq_len/ffn_mult(plus an evend_model / n_headshead width, which RoPE needs, checked at construction and in.builder().layers(...)before the divisibility arithmetic),NNConvParams.conv_channels/in_channels/kernel_size/stride/pool_size(positive) andpadding(≥ 0),NNMoEParams.num_experts/top_k,NNOptimParams.accumulate_grad_batches,NNSchedulerParams.patience/cooldown(≥ 0) andstep_size/T_max/total_steps/warmup_steps(> 0 when set),NNTrainParams.n_epochs,NNTrainerParams.n_epochs,EarlyStopping(patience)and directRoPE(dim, max_seq_len). Any non-booleannumbers.Integral— NumPy integers included — is accepted and normalized to a plainint, sostate()stays YAML-portable and hashes like the plain-int configuration; booleans, every float (2.0included), numeric strings and NaN / ±infinity raise aValueErrornaming the field before anyrange, modulo,isqrtor layer allocation. Previouslyaccumulate_grad_batches=2.5reached the modulo and silently stepped every five batches, and a NumPy-integer count maderun.yamlserialization fail.Nonesentinels, zero-valued padding / patience / cooldown and real-valued hyperparameters are unchanged. - Public real-valued hyperparameters now reject NaN and ±infinity (and non-real values such as booleans or numeric strings) at their configuration boundary with a
ValueErrornaming the field and its domain:NNOptimParams(max_lr,weight_decay,grad_clip_norm, SGDmomentum, Adam betas),NNSchedulerParams(factor,min_lr,threshold,max_lr),NNParamGroupSpec(lr,lr_multiplier,weight_decay, plus the resolved per-group learning rate inbuild_param_groups),NNTransformerParams.rope_baseand directRoPE(base=...)(which must be positive and now cannot produce non-finite tables),RMSNorm(eps),EarlyStopping(min_delta)and LoRA / DoRAalpha. Meaningful zeros andNonesentinels stay valid and serialized state is unchanged. deepennow honours dotted names for ReLU insertion in namednn.Sequentialcontainers (root or nested): the site is located by registered key instead ofint()coercion, the container is rebuilt from its ordered_modulesso a module registered in several slots keeps every slot (a positional rebuild throughchildren()used to drop one and silently corrupt the forward), the inserted modules get collision-free_nnx_deepen_linear_N/_nnx_deepen_relu_Nkeys, the container's training mode is preserved, and the surgery path helpers address a registered key exactly so retained numeric keys stay reachable after an insertion.deepenon aFeedFwdNNnow updates the network's immutableparamstogether with itslayers: one morehidden_dimsentry at the insertion site, a ReLU inserted into an explicitactivationslist, and dropout0.0at the new identity site with every existing site's probability preserved (a nonzero scalar dropout is materialized into a per-layer list). The ReLU check reads the effective site activation (activation_for(i)), so a per-layer override is honoured over the scalar default in both directions. Previously the forward of a deepened net with per-layer overrides raisedIndexError, and the inserted layer inherited the scalar dropout.widennow validates the target→consumer path before allocating anything: the consumer is inferred only insidenn.SequentialorFeedFwdNN.layers(resolving the effective per-layer activation/dropout, not the scalar defaults) and only through elementwise ops; Softmax / LayerNorm / BatchNorm boundaries, other containers whose registration order is not data flow, missing consumers and modules registered under more than one path raise aValueErrornaming the boundary with the source model, module identities and RNG untouched. Previously aLinear → Softmax → Linearnet was silently widened with a 0.12 output drift.widenandlow_rank_factorizenow preserve trainability and module modes: the widened target and its downstream consumer, and both SVD factors, inherit therequires_gradflag of the weight/bias they replace (bias flags independently;up.biascarries the source bias role) and the train/eval mode of the module they replace, instead of defaulting every new tensor back to trainable/train mode — an optimizer built after surgery no longer silently updates previously frozen content.- Manual attention (the path taken when
attn_dropout > 0in train mode) now accumulates scores, additive mask, softmax and dropout in FP32 for FP16/BF16 inputs (FP32 and FP64 keep their own dtype) and casts the result back to the value dtype, so a half or bf16TransformerNN/PrefixTunertrains with attention dropout instead of failing withexpected scalar type Float but found Half; theattn_dropout=0SDPA path, seeded determinism, causality and eval-mode behaviour are unchanged. - Adapter-only PEFT artifacts are now selected by registered ownership instead of key substrings:
save_lora_weights/load_lora_weights(thelora_A/lora_Bof eachLoRALinear, DoRA included),save_ia3_weights/load_ia3_weights(eachIA3Linear.scaling),PromptTuner.prompt_state_dict/load_prompt_weights(soft_prompt) andPrefixTuner.prefix_state_dict/load_prefix_weights(prefix_keys.i/prefix_values.i). A module merely namedlora_A_projectionorscaling_projectionno longer leaks its frozenbase.*tensors into the file, a full or colliding source dict can no longer overwrite a frozen base tensor on load (the allowlist is built from the destination), and loaded counts report only accepted adapter tensors. File format and key names are unchanged. - PEFT adapters now allocate their parameters from the tensor they compose with, inheriting its dtype and device:
LoRALinear/DoRALinear(lora_A,lora_B) andIA3Linear(scaling) from the wrapped weight,PromptTuner(soft_prompt) from the token embedding, andPrefixTuner(per-layer K/V) from each block's attention projection. Wrapping a base that was already converted to half/double or moved to a device — or a meta-device skeleton — composes immediately without a second.to(), and LoRA, IA3,PromptTunerandPrefixTunerstill compose with a torchao int8-quantized base (quantize_int8), as in 0.2.3; IA3 no longer silently promotes a half pipeline to float32; the base is never moved or recast. DoRALinearnow normalizes rows in FP32 for FP16/BF16 layers (FP64 stays FP64) and casts the effective weight back to the layer dtype, so a zero or tiny row of the composed weight — after pruning, explicit zero initialization or a zero row in a loaded base — yields a finite output equal to the base instead of NaN (FP16 rounded the1e-8guard to zero), while learned nonzero adapter/magnitude updates stay differentiable and output dtype/device are unchanged.quantize_int8now returns an instance of the same class it was given (typed with a boundedTypeVar): aGenerativeNNModelkeeps its tokenizer andgenerate, and user subclasses keep their overrides and custom attributes, instead of being downgraded to a bareNNModel. The wrapper is a shallow copy (no constructor re-run, no RNG consumed) sharing params/loss/tokenizer with the source; onlynetis the independent quantized deep copy.- Warm resume now accepts the same batch sources ordinary training accepts — a
DataLoader, a plain re-iterable list of(X, Y)batches, orNNGraphDataset(sampler="full")'s one-element list — instead of failing withAttributeError: 'list' object has no attribute 'num_workers'after restoring state; worker capability is read safely before restoration, and the worker-RNG warning still fires for a real loader withnum_workers > 0.NNTrainParams.train_loader/val_loaderandNNModel.evaluateare annotated asIterableaccordingly. Losses.NEGATIVE_LOG_LIKELIHOODnow optimizes and reports the normalized negative log-likelihood: the supervised training step,evaluate()and the NNx-owned classifier step factories (KD, feature-KD, MoE, Mixup, CutMix, Born-Again) applylog_softmaxover the class axis before the nativetorch.nn.NLLLoss, so NLL and cross-entropy produce identical losses and parameter updates from the same weights (previously raw logits were passed in, yielding negative losses and a gradient without the competing-class term).predict().logitsstays raw; custom loss modules,NLLLosssubclasses with their ownforward, customtrain_step_fns andlr_findercallables keep receiving the raw output.- Overwriting the run that owns
runs/best(overwrite_existing=True) now re-elects the best surviving committed run before the old artifacts are deleted, instead of comparing the replacement with itself and leaving the pointer on a worse or emptied run; only runs with a committed BEST checkpoint can claim the pointer (reservations,.leases, malformed artifacts and speculative history beyond LAST are ignored), and the pointer is removed when no eligible run remains. NNCheckpoint.load_training_state,load_optimizer_stateandload_with_training_stateno longer createruns/<id>/checkpoints/when probing a run that does not exist: they return their absent result without touching the filesystem, so a preflight probe no longer makes the firsttrain()fail with "run already exists". Existing runs keep the checkpoint lock and generation validation; run IDs are still validated first.NNTrainerParamsnow snapshotsoptims,schedulersandextra_metricsinto read-only mappings at construction (direct constructor,builder().build(),from_state,dataclasses.replace): reusing a builder or mutating the dict you passed in no longer alters an already-built configuration, itsstate()or its run identity, and item assignment/deletion through the built mappings raisesTypeError. Values (params dataclasses, metric callables, loaders) keep their identity; serialized state is unchanged.- BEST-checkpoint selection, the
runs/bestpointer andReduceLROnPlateaustepping now use the first finite metric in the validation-error → validation-loss → training-error → training-loss order: NaN/±inf values are skipped with a per-epochRuntimeWarningnaming the rejected field, a non-finite validation error no longer hides the same epoch's finite loss, an epoch without any finite signal skips the plateau step and compares as an unavailable baseline, and legacy NaN-ranked BEST artifacts are replaced by the next finite candidate. Raw observations in run history and checkpoints are unchanged.
0.2.3 (2026-08-17)¶
Changed¶
- Maintenance and publishing tooling now use Setuptools 84.0.0, Markdown 3.10.3, and uv 0.12.3 consistently across requirements, workflows, documentation, and the frozen lockfile.
Fixed¶
- Documentation now renders a portable API catalog on all three surfaces, validates canonical GitHub links and tracked-file ownership, documents recent public APIs, and corrects stale DPO, comparison, tooling, and release-contract guidance.
- PyPI trusted publishing now runs directly in the top-level
release.ymlworkflow dispatched by Release Please, keeping the OIDC publisher and Sigstore attestation identities aligned instead of publishing from an unsupported reusable-workflow context. - Draft GitHub release assets are verified through the release database ID because GitHub's tag lookup endpoint does not expose draft releases.
0.2.2 (2026-08-09)¶
Added¶
NNTabularDatasetaccepts an optionaltarget_dtype(a floating-point dtype, e.g.torch.float32) to skip the integer-label cast and contiguity check and fixoutput_dim=1, adding first-class regression-target support without the previous "build the DataLoaders yourself" workaround; regression loaders yield targets shaped(batch, 1)to matchoutput_dim=1; integer dtypes are rejected with a clear error; the default (None) preserves the existing classification contract.NNRunaccepts an optionalsaltstring folded into the run.id hash, letting identical (model, net, train) configurations run as distinct experiments without modifying modeled params;salt=None(the default) preserves existing run.id hashes exactly; empty/whitespace salts are rejected with a clear error (mirrorsNNTrainParams.data_id).- Versioned warm-resume bundles now persist optimizer, scheduler, scaler, completed epoch, and Python/NumPy/PyTorch RNG state in generation-addressed sidecars, preserving the previous resumable generation across interrupted commits.
- Smoke coverage imports every numbered example and executes representative basic, callback, and custom-evaluation examples; release tests also verify normalized source distributions are reproducible.
- A project security policy documents supported versions, private vulnerability reporting, and responsible disclosure expectations.
- Trainer scheduler ownership is explicit through
auto_step_schedulers, with loop-owned once-per-epoch stepping as the default. - A manifest now deterministically projects canonical documentation into self-contained MkDocs and GitHub wiki trees.
dpo_train_step_factorynow surfacesreward_chosen,reward_rejected, andreward_accuracy(the fraction of the batch where the implicit chosen reward exceeds the rejected reward) in eachNNEvaluationDataPoint.extra, giving DPO training runs a ranking-quality diagnostic alongside the existing log-prob gap.GenerativeNNModel.generate()accepts an optionalon_tokencallback invoked after each generated token, enabling streaming output and progress reporting without re-running decode.
Changed¶
- Documentation now opens with a centered product identity, synchronized health and technology badges, a wide brand banner, and an executive summary; navigation numbering remains independent from the unnumbered product H1 across the repository, Pages, wiki, and PyPI projection.
- GitHub workflows use current SHA-pinned actions, least-privilege permissions, frozen dependency and tooling resolution, a double-build artifact comparison, and one Release Please-only publication path; managed release PRs refresh the lockfile and dispatch required checks, security audits cover pushes and pull requests on both Gitflow branches, and exact PyPI/GitHub hashes and immutable-release attestations are verified before completion.
- Release builds use the locked virtual-environment interpreter, dependency audits include locked tool groups, and the release toolchain tracks wheel 0.47.0.
- Package metadata uses a deterministic PyPI README projection whose repository links and images resolve outside GitHub.
- Optional notebook display support moved out of the core dependency set, while process-safe persistence locking is now an explicit runtime dependency.
- Examples and public documentation now describe experimental GGUF/Ollama export, whole-loader evaluation hooks, partial gradient accumulation, and complete resume semantics accurately.
- Warm resume restores loader and sampler generators by stable identity and validates optimizer type, parameter topology, and scheduler identity before applying state.
Fixed¶
- Final partial accumulation windows now perform an optimizer step, and custom evaluation runs once per epoch over the complete validation loader.
- Gradient accumulation now identifies the observed final batch and weights uneven batches by sample count, matching one combined effective-batch update.
- Accumulation and evaluation use the effective CE/NLL denominator for class weights and ignored targets, preserving combined-batch loss semantics.
- Accumulation and evaluation honor configured loss hooks and mean/sum reductions; fully ignored cycles fail before optimizer updates.
- Ignored CE/NLL targets are excluded from classification metrics, custom subclasses keep their own normalization contract, and LR finding rejects live persistent-worker iterators it cannot rewind.
- Dataset, sampler, callback, learning-rate finder, trainer, and Ollama option boundaries reject invalid, non-finite, fractional, unsafe, or unknown inputs before execution.
- Run identity includes data lineage, mutable parameter defaults are isolated, callback-finalized trainer checkpoints preserve transforms, and born-again generations receive distinct lineage-aware runs.
- Checkpoint and run writes use process-safe locks plus unique atomic temporary files; LAST commits an epoch only after history persists, callback checkpoints flush after that boundary, and failed commits roll history back.
- Model snapshots preserve PyTorch state-dict metadata and non-tensor extra state; tensor-only safetensors and Hub exports now reject incompatible extra state with a targeted error.
- Learning-rate finding restores Python and NumPy global RNG state, and warm-resume bundles now preserve Apple MPS RNG state alongside CPU and CUDA state.
- Inference and inspection helpers restore mixed per-module train/eval modes exactly instead of flattening every child to the root mode.
- Mode restoration invokes recursive
Module.train()only at actual mode boundaries, avoiding redundant custom-hook side effects in homogeneous subtrees. - Mixed-mode restoration invokes each custom
Module.train()hook exactly once, and checkpoint cleanup enumerates literal directories so metacharacters in root paths cannot affect siblings. - Child-aware mode hooks retain access to registered modules, probability-target cross entropy reports class-index metrics, and pickle checkpoints/sidecars load portably onto CPU by default.
- Mode restoration suppresses direct calls to any descendant hook, and legacy feed-forward/transformer file loaders also default to CPU with an override.
- Transformer token logits now flatten correctly for standard cross-entropy training, mode hooks restore bottom-up, high-level checkpoint reconstruction accepts a device override, and safetensors honors load placement.
- Transformer probability targets flatten along the class-last layout, predictions select the final class axis, malformed KV caches fail clearly, Hub loads override serialized device metadata, and safetensors rejects unsupported location mappings.
- Transformer-only class-last handling no longer changes class-first segmentation outputs, and Hub loading rejects indexed devices that the serialized device contract cannot preserve.
- Checkpoint reconstruction preserves subclasses, Transformer KV caches validate their full tensor contract, and Hub safetensors loading normalizes
torch.devicelocations. - Transformer cache validation supports active autocast, checks key/value symmetry and tuple shape, and subclass-preserving reconstruction is reflected in static
Selftyping. - Multidimensional probability-target metrics flatten consistently, generative checkpoint reconstruction accepts its tokenizer, and Hub artifacts package tokenizer and topology-transform metadata for loadable generative and converted-QAT round trips.
- Classification error follows subset accuracy for multidimensional labels, cross-entropy subclasses retain metric preprocessing, built-in binary classification uses logits with a zero threshold, Trainer exports retain completed topology transforms, and remote Hub artifacts load from one immutable snapshot.
- Soft binary targets are thresholded only for classification metrics, preventing valid BCE training from failing after an optimizer update.
- Persistent NNModel training rejects low-rank surgery without a reconstruction recipe before mutating state; the surgery example uses manual refinement and state-dict export semantics.
- Explicit custom training steps remain available for reconstructibility-aware custom topologies such as diffusion, and frozen evaluation records store custom metrics in an immutable hashable mapping.
- Inherited native CE/NLL losses retain class-weight normalization, and repeated NNModel or Trainer transformations compose reconstruction recipes instead of replacing them.
- Optimizerless checkpoints explicitly reject stale legacy sidecars after interrupted cleanup, warm resume rejects GradScaler presence changes, and mode restoration honors custom
Module.train()hooks. - Run identifiers reject glob metacharacters before sidecar cleanup, and subclasses of built-in elementwise losses retain the custom-loss normalization contract.
- Documentation projection rejects missing local links, malformed nested manifests, unsafe output slugs and collisions, source paths outside the repository, symlink destinations, repository ancestors, and nonempty unmanaged outputs.
- Documentation projection checks are non-mutating, fenced examples are ignored during link processing, and cleanup rejects symlinks in every output path component.
- Documentation projection preserves inline code and link titles, handles nested fence-like examples correctly, and rejects unmapped repository-file links instead of erasing them.
- Documentation projection rewrites reference-style links, rejects nonportable root-relative URLs, and validates setext headings and explicit anchor IDs.
- Documentation projection uses balanced escape-aware link scanning, decoded local-path lookup, HTML-comment exclusion, and Python-Markdown anchor slugs.
- Documentation projection tokenizes multiline links/references, indented code, contextual comments, and blockquoted headings correctly.
- Documentation projection rejects image traversal and unknown manifest keys, suppresses blockquoted fences and raw
<pre>blocks, and derives wiki anchors from rendered GitHub-style heading text. - Documentation projection respects blockquote and raw-HTML containers, rewrites and validates HTML links/images, rejects unpublished image sources, and allocates globally unique wiki anchors.
- Documentation projection handles quoted and unquoted HTML targets, list-contained fences/headings, site superfence anchors, and nested wiki asset paths without flattening collisions.
- Documentation projection uses offset-preserving HTML attribute scanning, excludes literal blocks from HTML processing and anchor discovery, preserves nested assets on both surfaces, and rejects duplicate manifest keys.
- Documentation projection enforces output containment, depth-aware raw/list containers, MkDocs extension parity, complete HTML resource attributes, contained assets, unique attributes, and HTML-entity lookup.
- Documentation projection rejects asset-directory symlinks, distinguishes list prose from code, covers literal HTML forms and standard resources, preserves data-URL
srcset, and derives wiki anchors from rendered structure. - Documentation projection handles mixed
srcset, complete URL-bearing attributes, blockquoted raw HTML, list references/explicit IDs, independent heading suffixes, and copied HTML/CSS validation. - Documentation projection separates descriptorless data-URI
srcsetcandidates, honors blank-line raw-HTML termination, projects nested-list IDs, parses CSS comments/imports, accepts the standard macOS/tmpalias, and type-checks its scripts. - Documentation projection preserves GFM raw-HTML blocks through the following blank line, recognizes nested-list HTML, validates embedded CSS and copied SVG resources, and rejects incomplete projected assets.
- Documentation projection closes raw/comment blocks at list boundaries, enforces declaration case, projects nested Setext IDs, tokenizes CSS functions and strings, validates additional SVG references, and rejects empty manifest groups.
- Documentation projection handles list-contained literal HTML, CSS escapes and image sets, literal-code wiki headings, broader SVG references, and balanced nested SVG diagram extraction.
- Documentation projection closes processing-instruction and declaration literals at list boundaries, consumes CSS escapes while scanning nested functions, and validates SVG stylesheet processing instructions.
- Documentation projection measures tab indentation in Markdown columns, handles escaped CSS imports and line continuations plus nested
image(), and ignores SVG stylesheet text inside comments and CDATA. - Documentation projection applies tab columns to fences and reference definitions, covers directional CSS
image(), tokenizes stylesheet PI attributes, accepts standard macOS temporary aliases, and wiki publication can initialize an empty remote. - Documentation projection preserves partial tab-stop indentation, normalizes invalid CSS escapes to U+FFFD, and rejects duplicate or malformed XML stylesheet pseudo-attributes.
- Documentation projection recognizes a dedented closing marker for a list-contained fence instead of reopening suppression across following prose.
- Safetensors checkpoints reject missing or unsupported format versions instead of attempting an incompatible load.
- The architecture diagram now renders completely on desktop while retaining a contained, readable horizontal scroller on small screens.
Security¶
- Dependency alerts, Dependabot security updates, private vulnerability reporting, secret scanning with push protection, CodeQL default setup, immutable releases, and protected merge-only Gitflow rules are enabled at the repository level.
- Required gitflow checks are bound to the GitHub Actions integration rather than accepting same-named statuses from arbitrary writers.
- The v0.2.1 GitHub release now carries the exact wheel and source distribution published to PyPI; repository immutable releases are enabled for subsequent publications.
0.2.1 (2026-07-22)¶
Features¶
- ConvNN (LeNet-style) + NNConvParams + Nets.CONV (#89) (46fdfef)
- dataset: full-batch node-classification loader (no pyg-lib) for NNGraphDataset (2b8b163)
- FeedFwdMoENN net + Nets.FEED_FWD_MOE + NNMoEParams (5d3956f), closes #88
- per-layer activation & dropout on NNParams (net-wide → optional lists) (4a5de0c), closes #85
- pluggable eval_step_fn on NNModel.train (mirror train_step_fn) (822b6da), closes #86
Bug Fixes¶
- complete specialized network exports (91ff6ef)
- harden training lifecycle and type contracts (c6b322e)
- promote GitPython security refresh (1c03f56)
- re-save LAST checkpoint after on_train_end so callback net mutations persist (65e599c), closes #87
- reconstruct converted QAT checkpoints (24d3262)
- reject missing ConvNN scalar activation (3476d2e)
- reject single-expert MoE params (#114) (a395e2d)
- require hidden layers for MoE params (#116) (70bf913)
- support pandas 2 type contracts (d9c523e)
Documentation¶
- examples: propagate the new primitives (#85–#89) to the examples (16fa58b)
- link the rendered docs site from README (#68) (3cf1682)
- render architecture diagram in a themed page (fix bare-HTML nav break) (#69) (8940c3e)
- Slate + Cobalt theme for the docs site (#66) (7520455)
- synchronize architecture and publishing surfaces (14a4d2c)
Changed¶
- Release verification now covers NNx Studio's required API floor. The clean-environment post-PyPI smoke imports
NNMoEParams,NNConvParams,FeedFwdMoENN, andConvNNin addition to checkingnnx.__version__, preventing a correctly numbered but API-incomplete wheel from satisfying the release gate. Release documentation now explicitly prohibits distributing artifacts built from untagged commits under the static release-please-managed version. - Public architecture and evaluation surfaces are complete.
ConvNNandFeedFwdMoENNnow follow the other first-class networks onto the top-levelnnxfacade; the API reference includesEvalStepContext/EvalStepFn,NNConvParams/ConvNN, andNNMoEParams/FeedFwdMoENN; and the concepts guide documents the shipped custom-validation hook instead of listing it as future work. Full-batch graph behavior and per-layer activation/dropout configuration remain covered by their existing guides and tests. - GGUF support is labeled experimental at every user-facing surface. NNx writes a structurally valid
nnx_transformercontainer, but stock llama.cpp, Ollama, and LM Studio do not implement that architecture. Docs and examples no longer recommend relabeling it as LLaMA or claim stock serving compatibility, and the quantization recipe points to the official llama.cpp source build. - CI, docs, security, and release jobs now install the frozen all-extras graph. Pyright warnings are a required CI gate, security audits the exact exported lock, build tools are pinned, release-please invokes the reusable publisher in the release-creation run, and the source-tree fallback version participates in release-please updates.
- Added dependency/security maintenance automation. Dependabot now watches GitHub Actions and Python dependency manifests weekly, and a new
securityworkflow runspip-auditagainst the project dependency graph on relevant PRs, weekly schedule, and manual dispatch. - Pinned GitHub Actions workflows to immutable SHAs. CI, docs, release, release-please, and security workflows now execute exact action commits, with comments preserving the source tag/branch each SHA was resolved from.
- Added a committed dependency lock and drift gate.
uv.locknow captures the resolved dependency graph,requirements-tools.txtpins the resolver tool, README documents frozenuvsync, and CI/release gates runuv lock --checkbefore installing test extras. - Relicensed from MIT to the Apache License 2.0. The
LICENSEfile now carries the full Apache 2.0 text and the package metadata declareslicense = "Apache-2.0"(SPDX). Apache 2.0 adds an explicit patent grant and patent-retaliation termination clause on top of MIT's permissions; it remains a permissive license. Copyright holder is unchanged (Kaveh Razavi).
Fixed¶
- Converted QAT checkpoints reconstruct through
NNModel.from_checkpoint. The post-training save correctly persisted torchao's converted state, but the generic loader still built an FP32 network and could not absorb its quantized keys.NNCheckpointnow carries ordered, versioned topology-transform recipes in both pickle and safetensors formats;QATLifecycleCallbackrecordsqat_configandgroupsize, and the loader replays that conversion before loading weights. Existing FP32 checkpoints keep an empty transform list, unknown recipes fail explicitly, and legacy converted QAT checkpoints without metadata receive a targeted compatibility error. NNConvParamsrequires a scalar activation at construction. Convolution blocks always use the net-wide scalar even whenactivationssupplies per-layer overrides for the fully connected head, soactivation=Nonepreviously constructed successfully and failed only on the firstConvNNforward. Direct and serialized construction now reject that invalid configuration at the params boundary; baseNNParamsstill permitsactivation=Nonewhen complete per-layer overrides are present.NNMoEParamsrequires at least one hidden layer. The base params type intentionally permits a no-hidden-layer linear classifier, but carrying that allowance intoFeedFwdMoENNproduced a model with only its plain classifier head: zeroMoELinearmodules and a silently inactive MoE auxiliary loss. Both direct and serialized construction now rejecthidden_dims=None/[], while plainNNParamsretains its linear-classifier behavior.NNMoEParamsnow rejectsnum_experts < 2at the parameter boundary. The serialized params type previously accepted a single expert even thoughMoELinearcorrectly rejected it during model construction, forcing downstream consumers to duplicate the stronger invariant. Direct construction andfrom_state()now fail early with the samenum_experts >= 2contract; valid MoE state and checkpoint formats are unchanged.- Trainer LAST checkpoints include
on_train_endmutations. The final checkpoint is refreshed after callback finalization, matching the persisted model to the completed lifecycle. - Empty aggregation and prediction fail clearly.
NNEvaluationDataPoint.mean_of([])andNNModel.predict()on an empty loader now raise targetedValueErrors instead of producing warnings, NaNs, or NumPy-internal failures. - Network enum dispatch rejects incompatible specialized params. Convolutional, MoE, and transformer network variants now require their matching params classes instead of constructing a mismatched network and failing later.
- Frozen dependencies contain no known audited vulnerabilities. Pygments, setuptools, torch, and torchvision were refreshed to patched resolutions, and the security workflow now audits that exact graph.
NNModel.train/Trainer.traincallback cleanup is complete and exception-safe. Only callbacks whose begin hook completed are finalized, finalizers run in reverse order, every cleanup hook gets a chance to run, and cleanup errors no longer mask the original training failure. Begin-hook failures still clean up callbacks that started earlier.default_train_steprejects non-finite loss before backward/step. The guard previously raised after gradient propagation and optimizer stepping, so a NaN/Inf loss could poison model weights before the exception reached the caller. The finite-loss check now runs immediately after loss computation on both AMP and non-AMP paths, before any gradients or optimizer state are mutated.- ONNX export fallback no longer masks real
TypeErrors.NNModel.to_onnxandnnx.viz.netron_exportnow check whether the installedtorch.onnx.exportsignature supports thedynamokeyword before deciding whether to omit it for legacy torch versions. Genuine exporter/modelTypeErrors now propagate with their original message instead of being rewritten as an old-torch compatibility error. VisUtils.two_dim_tsne_checkpoint_logitsnow honorsn_samplesacross test-loader batches. The helper previously read only the first test batch and then sliced it, so largern_samplesvalues silently capped at batch size. It now collects batches until the requested sample count, validates that at least two samples are available, uses a valid small-sample t-SNE perplexity, and defaultsrandom_state=0for reproducible plots.drop_layerrejects an ambiguousimportance=scorer for a single target. The scorer only participates whenlayer_nameis a candidate list; passing it with one string target previously looked meaningful but was silently ignored.drop_layer(..., layer_name="layers.1", importance=fn)now raises a clearValueErrorinstead, whiledrop_layer(..., layer_name=[...], importance=fn)keeps the minimum-score selection behavior.NNOptimParamsfails fast on out-of-rangeaccumulate_grad_batches/grad_clip_norm. Both fields constructed fine but misbehaved deep in the train loop:accumulate_grad_batches=0died mid-training withZeroDivisionErroronbatch_idx % accumulate_grad_batches(after printing the whole run-config table), a negative value silently scaled the loss by1/N < 0and performed gradient ascent, andgrad_clip_norm=0.0passed theis not Noneclip-enable check and zeroed every gradient so training ran to completion making no progress.__post_init__now raises a clearValueErrorforaccumulate_grad_batches < 1and forgrad_clip_norm <= 0(useNone, not0, to disable clipping) — same construction-time fail-fast convention already used forparam_groupsand the dataset classes.train_contrastivefails fast on a non-positivegrad_clip_norm. The high-level embeddings fine-tune loop validatedn_epochs/batch_size/temperatureat the boundary but notgrad_clip_norm, sograd_clip_norm=0.0slipped past theis not Noneclip-enable check andclip_grad_norm_(..., 0.0)zeroed every gradient — the backbone trained to completion making no progress. It now raises aValueErrorfor anygrad_clip_norm <= 0(useNone, not0, to disable), matching the same fail-fast conventionNNOptimParamsalready enforces for the identical footgun.NNModel.train/Trainer.trainno longer print "Run saved to X" before the save actually completes. Both wrappers ended withprint(...); return run.save()— if.save()raised (disk full, permissions, path inaccessible), the success line had already reached the user, contradicting the exception that followed. Both now save first and print only after, so a failed save surfaces only the real exception. Incremental per-epoch checkpoints already wrote toruns/<id>/throughout the loop, so the directory existed when the print fired; the message ordering is the only thing that changed.NNParams/NNTransformerParamsfail fast on non-positive architectural dimensions. Several__post_init__gaps let a degenerate config construct silently and misbehave far downstream: onNNTransformerParams,d_model=0passed the0 % n_heads == 0divisibility check and then zeroedhead_dim(a zero attention-scale divisor) and the FFN width, whilen_layers<=0built an attention-freeembed→norm→headmodel with no error at all — both producing a stable-lookingrun.idfor a silently-wrong model. The baseNNParamslikewise validated none ofinput_dim/output_dim/hidden_dims/dropout_prob.NNParams.__post_init__now requiresinput_dim > 0,output_dim > 0, everyhidden_dimsentry> 0, and0.0 <= dropout_prob <= 1.0;NNTransformerParams.__post_init__additionally requiresvocab_size/n_layers/d_model/max_seq_len/ffn_multall> 0(withd_modelchecked before then_headsdivisibility test so the zero case can't mask itself) and0.0 <= attn_dropout <= 1.0/0.0 <= resid_dropout <= 1.0so all three dropout knobs validate together (an out-of-rangeattn_dropout/resid_dropoutpreviously surfaced only at the first training forward). Same construction-time fail-fast convention already used forn_heads,accumulate_grad_batches, and the dataset classes; no field touchesstate(), so every valid config keeps itsrun.id.NNSchedulerParamsvalidates its numeric fields. The dataclass had no__post_init__at all, sofactor<=0, negativemin_lr/threshold/patience/cooldown, and non-positive variant knobs (step_size/T_max/max_lr/total_steps/warmup_steps) constructed fine and only surfaced — if ever — deep inside the relevant torch scheduler constructor. It now raises a clearValueErrorat construction time (factor > 0, the non-negative bounds, and> 0for any present variant knob). Validation only rejects invalid configs — it never mutatesstate()— so arun.idnever shifts for any valid config (the optional variant knobs still omit themselves at their defaults; the required fields are emitted unchanged as before).NNTrainParams/NNTrainerParamsfail fast onn_epochs < 1.n_epochsdrivesrange(params.n_epochs)in both train loops, so0/negative made training a silent no-op (emptyidps, a degenerate saved run, no BEST checkpoint) after printing the run-config table, rather than erroring. Both now raise aValueErrorat construction (NNTrainParamsgains its first__post_init__;NNTrainerParamsextends its existing one), symmetric across the two train paths.ViTNN/ViTBlock/JEPAPredictor/QATLifecycleCallbackfail fast on non-positive dimensions. These public constructors take raw numeric kwargs that bypass the params dataclasses, so they carried the same silent-degenerate footgun the params guards close:n_layers=0built an attention-free model,ffn_mult=0a zero-width SwiGLU FFN,d_model=0masked itself through then_headsdivisibility check (andimage_sizenegative through thepatch_sizeone), and a non-positive QATgroupsizesilently mis-quantized (or crashed cryptically atgroupsize=0) deep insideprepare().ViTNN,ViTBlock, andJEPAPredictornow validate every architectural dimension> 0at the top of__init__(positive-dim checks ordered before the divisibility tests so the zero case can't mask itself), andViTNN/ViTBlockadditionally bound theirattn_dropout/resid_dropoutto[0, 1]; QAT routes through the shared_build_quantizerchokepoint which now requiresgroupsize > 0.DiffusionMLP(which already validatedinput_dim/time_embed_dim) now also requires everyhidden_dimsentry> 0, matchingNNParams. None of these touch any serialized state.NNGraphDatasetneighbor sampling is now reproducible (the last dataset holdout). It was the only dataset whose loaders both shuffle (train_loader) and spawn worker processes (n_workers=4by default) yet threaded neither ageneratornor aworker_init_fn— so neighbor sampling was non-deterministic across runs even afternnx.set_seed(...), unlike therandom_split-based siblings (NNDataset/NNTabularDataset/NNPreferenceDataset) which already exposedseed. It now acceptsseed: Optional[int] = Noneand threads a seededgeneratorplus the shareddataloader_worker_init_fninto all threeNeighborLoaders.seed=Nonekeeps the prior behavior by falling back totorch.default_generator. First behavioral test coverage for the class accompanies the fix.MoELinearfails fast on non-positivein_features/out_features. The publicMoELinearconstructor validatednum_experts/top_kbut not its feature dims, soin_features=0orout_features=0built a silently-degenerate routing layer — PyTorch emits only aUserWarningon a zero-dimnn.Linear. It now raisesValueErrorfor either, completing the positive-dimension fail-fast convention the params dataclasses and the other public net constructors (ViTNN/ViTBlock/JEPAPredictor/DiffusionMLP) already enforce. No serialized state is affected.- I-JEPA
jepa_train_step_factoryzeros the predictor's gradients explicitly. The step calledmodel.net.zero_grad()but notpredictor.zero_grad(). The recommended path (predictor registered as amodel.netsubmodule) was already covered, but in the docstring's alternate path — the predictor's parameters added to the optimizer directly, outsidemodel.net— its gradients accumulated across steps while the optimizer still stepped them. The step now zeros the predictor explicitly (a no-op in the submodule path). - PEFT adapter loaders restore GPU-saved adapters on a CPU-only machine.
load_lora_weights/load_ia3_weights/load_prefix_weights/load_prompt_weightsresolve a checkpoint path through the shared_resolve_source_to_state_dicthelper, which usedweights_only=Truebut nomap_location— so an adapter trained andtorch.save-d on CUDA raised when loaded on a CPU-only host. It now loads withmap_location="cpu"(the downstreamload_state_dictcopies the tensors onto the target module's device), matchingnnx.finetune.load_pretrained. nnx.viz.gradient_flowaccepts anNNModel. It was the onlynnx.vizfunction typednn.Module-only; the other five (summary/weight_histogram/activation_map/attribute/netron_export) accept anNNModeland unwrap to.net, so passing anNNModel(as callers do for the others) raisedAttributeError. It now unwraps.netlike its siblings.NNParamGroupSpecfails fast on a non-positivelr/lr_multiplieror a negativeweight_decay. A per-grouplrorlr_multiplierof0silently zeroed the group's effective learning rate (its parameters never updated), and a negative value ran gradient ascent on that group — the same lr/scale footgunNNOptimParamsandNNSchedulerParamsalready guard.__post_init__now requireslr > 0,lr_multiplier > 0, andweight_decay >= 0(0still disables weight decay for the group). No serialized state is affected.- Multi-optimizer
Trainerrejects optimizers that don't scope their parameters. With more than one optimizer, anNNOptimParamswithparam_groups=Noneroutes to allnet.parameters(), so two such optimizers silently double-stepped every parameter — violating the disjoint-partition contract the multi-optimTrainerexists to provide.Trainer.trainnow fails fast with an actionable error naming the unscoped optimizers; theNNTrainerParamsobject stays constructible (serialization / builder round-trips are unaffected). NNOptimParamsfails fast on a negativemax_lrorweight_decay. Validated at params-construction time (the NNx fail-fast convention) rather than surfacing deep in optimizer construction or — for a negativeweight_decay, which some PyTorch optimizers accept silently — as gradual weight growth during training. The per-groupNNParamGroupSpecalready rejected a negativeweight_decay; the top-level fields now match.__post_init__requiresmax_lr >= 0andweight_decay >= 0.max_lr == 0stays valid as an explicit "freeze updates" value andweight_decay == 0disables decay; no serialized state is affected.
[0.2.0] — 2026-06-13 — Expansion megamerge + Month-1 cluster + overnight-maintenance + Builder rollout + PyPI rename¶
Spans the PR #29 megamerge (20 sub-projects) + PRs #30–#41 (Month-1 cluster + first overnight-maintenance pass) + PRs #42–#46, #48 (security fix + 5-PR Builder-pattern rollout + LogitsChain) + PRs #47, #49 (two-step PyPI distribution rename nnx → nnx-pytorch → thekaveh-nnx) + PR #50 (post-#49 overnight-maintenance — Builder correctness backfill) + PR #51 (post-#50 overnight-maintenance — 5 correctness fixes + docs sync) + PR #52 (post-#51 overnight-maintenance — phase-tag refactor + Builder boundary + 4 docs/test fixes) + PR #53 (post-PR-#52 overnight-maintenance — subpackage __all__ consistency + PEFT source-resolution DRY + idiom + Raises:) + PR #54 (post-#53 overnight-maintenance — reproducibility hardening + ergonomics) + the post-PR-#54 overnight-maintenance pass (the Fixed section directly below). Test suite is 881 tests; 879 pass, 2 skip (the CUDA-gated 2:4 semi-structured sparsity path skips on CPU runners; the network-gated test_pypi_lists_the_current_distribution_name skips on PyPI 404 until the first release ships).
Fixed — post-PR-#54 overnight-maintenance pass¶
- Transformer params no longer silently downgrade on load. All three deserialization paths (
NNRun.load, theNNCheckpointsafetensors reader, hubfrom_pretrained) calledNNParams.from_state(...)unconditionally, so a state written byNNTransformerParams.state()came back as a baseNNParamswithvocab_size/n_layers/d_model/max_seq_lendropped — the reloaded run re-hashed to a differentrun.idand rebuilding the net crashed. NewNNParams.resolve_from_state(state)dispatches on the transformer keys; every loader funnels through it. Regression tests include an end-to-endNNRunsave/load asserting the reloaded run keeps itsNNTransformerParamsand its originalrun.id. activation=Nonenow survives thestate()round-trip.NNParams.state()serializedstr(None) == "None", whichActivations("None")could never parse back (ValueErroron reload).state()now stores a real null and bothfrom_stateconstructors restoreNone; a fully absent key still falls back to the legacy LEAKY_RELU default on the transformer path.- Loaded runs no longer get NaN-filled
val_edpon every row.NNIterationDataPoint.from_statecheckedis not Noneon flattened CSV cells, butpd.read_csvyields NaN (not None) for cells that were None at save time — so every reloaded idp got a NaN-filledNNEvaluationDataPoint, contradicting the documented only-the-last-idp-of-each-epoch contract and breakingidp.val_edp is not Noneconsumers. NaN cells now map back to None, both forval_edppresence and for the optionalloss/errorfields. - Dataset
seed=Nonegenuinely falls back to the global torch RNG. All three dataset classes (NNDataset,NNTabularDataset,NNPreferenceDataset) passed a freshtorch.Generator()torandom_splitwhenseed=None— but a fresh Generator always carries the same fixed default seed, so unseeded splits were bit-identical across runs and deaf totorch.manual_seed/set_seed, contrary to the documented contract.seed=Nonenow passestorch.default_generator; a regression test pins the contract by assertingtorch.manual_seedcontrols the split. - KV-cache decoding is positionally correct across sliding-window overflow. On overflow the cache path dropped the oldest cached k/v — but cached entries are RoPE-stamped at the absolute position they were written, so every later token's rotary offset was pinned at
max_seq_len - 1, drifting the sampler's logits ~0.1–0.5 vs the no-cache path on a tiny model (the existing token-level parity test passed only by argmax luck). The cache is now rebuilt from the current window on overflow — identical math to the no-cache sliding window, so greedy/seeded parity is exact; the O(T) cache win still applies within the window. New logits-level regression test spies on the raw logits entering the processor chain (greedy's temperature-0 scaling collapses post-chain logits to ±inf one-hots, which is what masked the drift). generate(max_new_tokens=0)emits zero tokens on both decode paths. The cache path's prefill unconditionally sampled one token before entering the loop, violating the documented hard cap (no-cache emitted 0, cache emitted 1). Plus first test coverage for thestop=contract.train()fails fast on a missingtrain_loader. BothNNModel.trainandTrainer.trainacceptedparams.train_loader=None(the dataclass default), printed the run-details table, then crashed mid-loop with a rawTypeError: 'NoneType' object is not iterable. Both now raise an actionableValueErrorat the boundary alongside the existingparams is Noneguards.from_pretrainedhonorsstrictand rejects unknown kwargs._from_pretrainedcomputedstrict=strict if strict else True— always True — sostrict=Falsewas impossible; it is now forwarded (default True, matching the real prior behavior). Unexpected**model_kwargsraiseTypeErrorinstead of vanishing silently (the mixin-injectednet_params/paramsconfig dicts are dropped knowingly), and the docstring's claim thatmap_locationwas ignored is corrected (it was always forwarded to safetensors).- CI + release gates install all 13 declared extras.
tensorboard,wandb, andonnxwere missing from both workflows' install lines, so the TensorBoardCallback events-file test silently skipped on every CI and release-gate run (the PR #29 failure mode),onnxcoverage was only transitively satisfied viaonnx-dynamo, andWandbCallbackhad zero happy-path exercise.release.ymlalso gains the workflow-levelpermissions: contents: readfloor thatci.ymlalready had. - Version bumped to 0.2.0. The dated CHANGELOG
[0.1.0]section (2026-05-18 extraction baseline, never tagged or published) already claims 0.1.0, so shipping the first real release as 0.1.0 would have produced two changelog sections claiming one version. The first PyPI release will ship this[Unreleased]content as 0.2.0. - PrefixTuner KV-cache decode corrected. The patched attention forward cached the prefix-injected K/V, so every cached decode step re-prepended the learned prefix on top of the cached copy (n_prefix duplicate slots per step) and computed the RoPE offset from a length inflated by the prefix —
generate(use_cache=True), the default, was silently wrong on prefix-tuned models (cached logits drifted ~2.0 from the full forward). The cache now snapshots real-token K/V before prefix injection; a parity regression test pins per-step logits equality and cache length. - PEFT loaders report tensors actually loaded. All four
load_*_weightsreturned the source-dict size, butload_state_dict(strict=False)silently drops keys the target doesn't have — loading a LoRA checkpoint into an un-adapted model reported 4 loaded when 0 landed.unexpected_keysare now subtracted at all four sites. - Surgery primitives thread device (and
deepenthreads dtype from the right layer).deepen's identity layer andlow_rank_factorize's two replacement Linears were constructed withoutdevice=, splicing CPU layers into CUDA-resident models (widenalready did this correctly).deepen's dtype probe also peeked atparent[idx-1]instead of the Linear that sourced the hidden dim, so a Dropout/norm between the Linear and the ReLU got a float32 layer spliced into a float64 model. export_to_safetensorshandles tied weights..contiguous()is a no-op on an already-contiguous storage-sharing tensor (tied embedding/head —TransformerNN's default), so safetensors raised "Some tensors share memory"; the cleaner now clones.write_ggufno longer leaks its file handle on error (GGUFWriter lifetime wrapped in try/finally); a tokenizer-parse failure during special-token collection now emits aRuntimeWarninginstead of silently emitting a GGUF with no CONTROL-marked tokens.- Edge-case crashes:
Utils.print_tree({})no longer raisesValueErrorfrommax()over zero keys;sinusoidal_time_embed(dim=2)no longer divides by zero (half==1 degenerates to a single unit frequency); the__version__fallback literals missed by the 0.2.0 bump are corrected. - Documentation truthfulness pass: the processor-chain order is documented as NNx's own canonical order (temperature deliberately last for the temperature-0 greedy markers) instead of falsely claiming HF equivalence;
concepts.md'sgenerate()signature,forward_with_cachename, non-destructive-contract count (ten sites), andfinalize_stepconsumer list corrected;comparison.md's auto-resume cell names both resume kwargs;embeddings.md's LangChain hand-off snippet now wraps the raw index instead of callingload_localon a bare file; README/lm.md KV-cache speedup claims qualified to withinmax_seq_len. - GNN training no longer leaks labels across splits.
_fwd_passscored every node in a NeighborLoader subgraph, but only the leadingbatch_sizerows are the batch's seed nodes — the appended sampled neighbors can belong to other splits, so val/test labels entered the training loss and train labels inflated val metrics (maximally so under the default full-split batch size). NewGraphNNBase.seed_countexposes the seed-row count; the default train step,evaluate, andpredict's loader loop slice to it. Plain full-graphDatabatches are unaffected. - DPO excludes pad positions from response log-probs.
NNPreferenceDatasetright-pads chosen/rejected responses, and the response log-prob summed every position — pad terms don't cancel between policy/reference or chosen/rejected, so the objective was biased and the gradient also trained the policy to emit pads.dpo_train_step_factorynow takespad_token_id(example 22 and docs pass the dataset's pad id);Nonepreserves the legacy behavior for genuinely unpadded responses. LINEAR_WARMUP_DECAYno longer trains the entire first epoch at LR=0. The warmup lambda returned0/warmup_stepsat step 0 and the scheduler steps once per epoch; the ramp is now 1-based.- Mixup/CutMix respect
set_seed. Both factories self-seedednp.random.default_rng()from OS entropy, so λ draws and CutMix boxes differed across identically-seeded runs; the numpy seed is now drawn through the torch RNG. EarlyStoppingresets per run._best/_waitpersisted acrosstrain()calls, so a reused instance compared the new run against the previous run's best and could stop it immediately; state now resets inon_train_begin.MoELinearaccepts(..., in_features)input like thenn.Linearit documents itself as a drop-in for (3-D sequence batches previously raised a crypticIndexError; tokens now route independently with leading dims restored), and the MoE step factory clears stalelast_aux_losstensors before each forward so a registered-but-unexercised MoE layer can't trigger "backward through the graph a second time".- PrefixTuner docstring +
deepenModuleList path device (the pass-2 surgery fix covered Sequential parents only); examples honesty pass: example 01 now learns (labels derive from inputs instead of pure noise), example 19 re-prunes after fine-tuning instead of reporting a silently-regrown 4.3%-sparse network as the 50%-sparse result, example 23 documents the per-generationruns/<id>/overwrite, example 09 acknowledges possible mode collapse. train_contrastiverejects degenerate batching (batch_size=1, or a dataset of < 2 pairs): NT-Xent on a single pair is identically 0.0 with zero gradients, so such configs silently trained nothing while printing 0.0 epoch means; the trailing size-1 batch is dropped only when it would actually have size 1.release.ymlasserts the pushed tag matches pyproject's version before the PyPI upload (a typo'd tag would have published the wrong version permanently, caught only post-upload).- The importorskip class is closed completely:
test_checkpoint_safetensors.py,test_hub_mixin.py(hub deps), andtest_viz_netron.py(onnx — needed by torch.onnx.export's proto save in every test) gained module-level guards; verified by running the three files with the extras import-blocked (3 clean skips, zero hard failures). - The two viz test files gained the
importorskipguard every other optional-extra file already had — running the shipped suite without the[viz]extra hard-failed 10 tests with raw ImportErrors instead of skipping. - The sdist ships a complete, runnable test suite — setuptools' legacy default glob included
tests/test_*.pybut silently droppedconftest.py/tests/__init__.py, leaving distro packagers a half-suite that fails with "fixture not found" (the conftest defines load-bearing fixtures and the macOS OMP guards). AMANIFEST.innow includes the suite whole. save_pretrainedworks on default (tied-embedding) transformers — the Hub writer was the third member of the tied-weights class (afterexport_to_safetensorsandNNCheckpoint.to_file) still missing the.clone(); every defaultTransformerNNcrashedsave_pretrained/push_to_hubwith "Some tensors share memory". Round-trip restores the tie and greedy-generation parity.torch.saveof a whole prefix-tuned net round-trips — the MethodType binding pickles by name-lookup on the instance, which a class-level alias now resolves (previously the save succeeded but the load died withAttributeError: a silently unloadable artifact).deepcopyof a prefix-tuned net is now fully independent. The patched attention forwards were instance closures, whichcopy.deepcopytreats as atomic — a copy's attention silently kept reading the ORIGINAL weights and prefix params, corrupting quantize-on-copy, born-again frozen teachers, and surgery copies of prefix-tuned nets. The patch is now aMethodType-bound module-level function with its references stored on the MHA module, so deepcopy rebinds to the copy and re-references through the memo.- Prompt-tuned
generate()survives long generations: the sliding window was sized fromnet_params.max_seq_len, but aPromptTunerconsumesn_prompt_tokensof those slots — generations crashed mid-stream once window + soft prompt exceeded the wrapped model's window.PromptTunernow advertiseseffective_max_seq_lenandgenerate()honors it. NNTokenizerParams.ofcreates the destination's parent directory (the lm/dpo quickstarts'path="artifacts/tok.json"from a fresh cwd previously failed with a cryptic Rust-side "No such file or directory"); surgery.md's freeze-old-rows recipe corrected twice over and now VERIFIED end-to-end: the expanded embedding must be reattached, and the embedding needs a decay-free param group because Adam applies weight decay inside step(), after gradient hooks.write_ggufcreates the destination's parent directory (the quickstart'swrite_gguf(net, tok, "out/model.gguf")from a fresh cwd previously raisedFileNotFoundError; the ollama exporter already mkdir'd).NNTabularDatasetrejectstarget_colinsidefeature_cols— that config silently trained the model on its own label (near-perfect val accuracy from the classicfeature_cols=list(df.columns)mistake).- Schedulers reject an explicit
total_steps < n_epochs— NNx steps schedulers once per EPOCH (not per batch, the HF habit), so a short total_steps made OneCycle raise mid-train at epoch total_steps+1 (losing that epoch's idps) and LINEAR_WARMUP_DECAY silently train every remaining epoch at LR=0.NNRun.loadalso rejects an empty/truncated run.yaml with the file path (safe_load → None previously died on a bare AttributeError). - Boundary hardening (pass 14): a fully-frozen model (
freeze('*')) is rejected attrain()entry on both loops instead of dying mid-loop with torch's raw does-not-require-grad error;NNTabularDatasetrejects NaN cells in modeled columns (NaN targets cast to int64 UNDEFINED — silently class 0 on ARM); malformed-artifact errors inNNRun.loadname the file that's actually corrupt (a dropped idps.csv column is no longer blamed on run.yaml). NNOptimParamsrejects plain dicts inparam_groupsat construction with a wrap-itTypeError— they previously crashed much later insidestate()duringNNRunhashing with an opaqueAttributeError.- Dataset construction fail-fast pass:
NNDatasetvalidatesval_proportion, rejects transform-less PIL samples with an actionable message ("pass ToTensor()"), and handlesval_proportion=0.0(previouslyDataLoader(batch_size=0)crashed);NNTabularDatasetrejects non-contiguous labels (e.g.{0, 5}) at construction instead of lettingnunique()-sized models fail much later inside cross-entropy;DiffusionMLPvalidatestime_embed_dimat init; the dataset base class types val/test loaders as Optional to match the documented empty-split contract. - Modelfile injection guard:
export_ollama_modelfilerejects triple-quotes insystem/template, whitespace in parameter keys, and newlines/quotes in string parameter values — before the expensive GGUF write — instead of emitting a Modelfile where user strings could terminate blocks early or inject directives. - Plotting correctness:
VisUtils.confusion_matrixpins sklearn'slabels=so an absent class no longer shifts every later row/column onto the wrong name;generate_colorsno longer gives the first and last class identical hues;multi_line_plot's legend mapping now matches its documented(group_labels, line_labels)contract (the two no-trace legend blocks were swapped against the data-trace hover names). lr_finderactually reachesend_lr(the exponent used1/num_iter, stopping one multiplicative step short of the documented sweep ceiling);load_pretrainedraises on remap collisions (prefix/key_map rewriting collapsing two source keys onto one target silently loaded whichever came last); freeze/prune/PEFT glob matching usesfnmatch.fnmatchcaseat all 8 sites (plainfnmatchis case-insensitive on Windows, so patterns matched differently across platforms);train_contrastivedrops the size-1 trailing batch (NT-Xent on a single pair is exactly 0.0 loss with zero grads, but the step still moved weights on stale Adam momentum and deflated the verbose epoch mean);weight_histogramskips empty tensors as its docstring promised; the freezing docstring example gains its missing wildcard.- Checkpoint format sniffing no longer misroutes safetensors files whose header length ≡ 128 mod 256. A safetensors file's first byte is the low byte of the u64 header length, which can legitimately be
0x80— the pickle PROTO opcode — soNNCheckpoint.from_filerouted such files totorch.load, which died with a confusingUnpicklingError. Byte 8 (the JSON header's{) now positively identifies safetensors before the pickle check. NNCheckpoint.to_file(format="safetensors")handles tied weights — every defaultTransformerNN(tied tok_embed/lm_head) crashed with "Some tensors share memory"; the writer now clones, and the reload keeps the tie intact.runs/besttracking fixes: the pointer repoint is now atomic (temp symlink +os.replace— a crash mid-repoint can no longer leave no pointer, which made the next save claimbestunconditionally); the symlink is created withtarget_is_directory=True(Windows-with-developer-mode created an untraversable file symlink, silently breaking best tracking); and_best_errfalls back val→train / error→loss via the shared resolver, so runs whose steps leave.errorunset (custom trainer/GAN step functions; the shipped diffusion/SimCLR/DPO factories do populate.error) no longer all score+infand freezebeston whichever run saved first.NNRun(...).save()with the dataclass-defaultidps=Nonewrites an empty idps.csv instead of raising a bareTypeError;NNTokenizerParams.ofsaves the tokenizer atomically (temp + rename) so an interrupt can't leave a truncated tokenizer.json thatfrom_statecan never load.generate()restores train mode on early raises and scopesstop=to the continuation. The eval() switch preceded tokenizer/parameter validation while the restoring try/finally only wrapped the decode loop, so an earlyValueErrorstranded the net in eval mode; and stop strings were searched in the full decoded text, so a prompt already containing one halted after a single token.- Both train loops reject zero-batch epochs (dataset smaller than batch_size with
drop_last=True, or a one-shot iterable exhausted in epoch 0) with an actionableValueError— previously the first epoch crashed on a bareIndexError, and a later zero-batch epoch would have silently attached itsval_edpto the previous epoch's last idp. TransformerNNgains GPT-2/LLaMA-style embedding init.nn.Embeddingdefaults to N(0,1); with the tied LM head the input token's own logit then includese·e ≈ d_model, so an untrained model started at CE ≈ d_model (123 measured at d_model=128 — worse than the uniform-random baseline ln(vocab) ≈ 5.7) and decoding degenerated into repeating the last prompt token regardless of sampling settings.std=0.02init on the shared tensor covers the tied head; example 11's generations recover.- GNN seed-slicing gated on
input_idso PyG multi-graphBatch.from_data_listcollations (which also carrybatch_size=num_graphs) aren't truncated to the graph count; pluspredict()-path coverage. MoELinearalso accepts the unbatched(in_features,)form; example 22 pads with the real<pad>id (1) instead of the default 0 =<unk>, which the DPO mask would otherwise drop from genuine responses.- Surgery primitives no longer consume the global torch RNG.
widen's comment claimed RNG-cleanliness (its duplication choices do use a local generator), but the fresh replacement layers in all four module-building primitives (widen×2,deepen's identity Linear,expand_embedding,low_rank_factorize×2) ran their default kaiming/normal init off the default generator before being fully overwritten — so any seeded caller pipeline silently diverged after a surgery call (the same reproducibility class as the examples seed-helper bugs). All six sites now build viatorch.nn.utils.skip_init(meta-device construction, zero RNG draws on any device); four regression tests pintorch.get_rng_state()equality across each primitive. lr_finderandviz.summary(input_size=)no longer perturb seeded pipelines. The follow-up sweep to the surgery skip_init fix:lr_finder's docstring promised the pre-flight sweep wouldn't disturb "any subsequent reproducibility", but its try/finally restored only weights + mode — the sweep's dropout draws (the helper itself calls.train()) and the DataLoader base-seed draw leaked into the caller's RNG stream, soseed → lr_finder → trainproduced different weights thanseed → train. The snapshot/restore now covers CPU + active-device RNG state in the same try/finally — plus any loader-attachedgenerator=at all four attachment points —DataLoader(generator=), an explicitsampler='s, one wrapped inside an explicitbatch_sampler=, and a custom batch sampler owning its generator directly (the official PyTorch reproducibility recipe routes shuffle permutations through these streams, which the global restore can't reach; three follow-up refutation passes each caught an escape). Non-torch generator objects riding the same attribute name (e.g. a numpyGeneratoron a custom sampler) are skipped instead of crashing the snapshot — they stay caller-owned. Withpersistent_workers=Truethe sweep also discards the cached iterator it created on the loader — otherwise the caller's first epoch_reset()s that cache instead of drawing a fresh worker base seed, shifting their batch stream even with every RNG snapshot faithfully restored.viz.summary(input_size=...)(the mid-pipeline probe idiom concepts.md recommends) advanced global RNG via torchinfo'storch.randdummy-input synthesis even though the values never affect the statistics; the wrapper now restores RNG around that path (input_data=was already clean). Both red-verified with state-equality regression tests.- Reentrancy + filesystem-robustness pass:
PrefixTunerrejects an already-prefix-tuned net — a second tuner silently hijacked the first (the patched forwards read the MHA's_nnx_prefix_tunerref, which the second tuner overwrote, so tuner-1's parameters stopped receiving gradients while its forward injected tuner-2's prefixes; training tuner-1 became a silent no-op).runs/bestsymlinks now use the sibling run-dir basename as the target — the rawrun_pathtarget resolved relative to the symlink's own directory, so a relativeroot=produced a dangling link from birth and every save took the repoint-unconditionally branch (besttracked the most recent run, not the best); the basename target also survives relocating the runs root. A zero-byteidps.csv(external truncation) now raises the per-filemalformed idps.csv at <path>error instead of pandas' context-freeEmptyDataError. - Coverage backfill: NNDataset behavioral tests + train-step-factory convention test.
NNDataset(the torchvision facade) previously had only anhasattrsmoke assertion —tests/test_nn_dataset.pynow exercises split arithmetic, full-split batch resolution, dim/class introspection, and both halves of theseedcontract against an in-memoryVisionDataset.tests/test_imports.pygains an AST-based scan asserting every*_train_step_factoryin the package is re-exported at top-levelnnx.*and listed innnx.__all__(the convention PR #54 had to repair by hand). Plus shared-helper consolidation: the classification step epilogue (4 copy-pasted sites) and the Hinton softened-KL block (2 sites) now live in one place each, andTrainerdelegates its checkpoint/tqdm tails to theNNModelimplementations instead of re-implementing them.
Changed — PyPI distribution name¶
- PyPI publication-verification tests + post-publish CI smoke job (PR #49). Three pytest layers in the new
tests/test_pypi_publication.py: (1) pyproject.toml[project] versionmust equalnnx.__version__; (2)importlib.metadata.version(<dist name>)must succeed and equalnnx.__version__(regression test catching the_version("nnx")lookup that PR #47 and the first cut of PR #49 both left stale —src/nnx/__init__.pynow uses_version("thekaveh-nnx")); (3) a network-gated check that PyPI lists the current dist name (skips on 404 when not-yet-published, skips on network error, skips onNNX_SKIP_PYPI_TESTS=1for offline contributors). Plus a newverify-publishedjob in.github/workflows/release.ymlthat runs afterpypa/gh-action-pypi-publishsucceeds: spins a fresh venv, retriespip install thekaveh-nnx==<tagged-version>up to 5× with 30s backoff (handles PyPI CDN propagation lag), then importsnnxand asserts__version__matches the tag. This is the contract the user really wants from "is the latest code actually on PyPI" — runs only on release events, asserts the exact uploaded version is reachable. Test count 745 → 748 (3 new tests; the network-gated one skips locally when offline / not-yet-published). - Renamed package on PyPI:
nnx→thekaveh-nnx(final name chosen via PR #49, after an intermediate pass throughnnx-pytorchin PR #47). Thennxname on PyPI is owned by an unrelated, abandoned JAX library bycgarciae(nnx==0.0.8, last release 2023-07-12), sopip install nnxwould silently install the wrong package — a JAX library with no overlap with this PyTorch toolkit. PR #47 first renamed tonnx-pytorch(generic-suffix convention); PR #49 then renamed again tothekaveh-nnx(username-prefix convention, matching the GitHub-handle namespace@thekaveh/). The import path staysimport nnxthrough both renames — only the install name changes (same pattern asPillow/PILorscikit-learn/sklearn). No wheel was ever published under either intermediate name, so this is a one-time correction sequence, not a deprecation chain. All install command references throughout this CHANGELOG, README, docs, examples, and source-code docstrings have been retroactively updated to the final namethekaveh-nnx.
Added — Builder pattern rollout¶
NNSchedulerParams.builder()— variant-gated construction (PR #43). Classic-GoF Builder reachable viaNNSchedulerParams.builder()returningNNSchedulerParamsBuilder(re-exported at top level asnnx.NNSchedulerParamsBuilder). Five variant methods —reduce_on_plateau,step,cosine_annealing,one_cycle,linear_warmup_decay— each setkindplus the variant-specific fields and leave everything else at the dataclass defaults, so the omit-when-defaultstate()invariant is preserved automatically (Builder only forwards user-touched fields). Existing direct-kwargNNSchedulerParams(...)ctor is untouched; the Builder is purely additive. 10 new tests cover happy-path per variant, state() round-trip, the "last variant wins" overwrite contract, build-without-variant rejection, the omit-when-default invariant, and a top-level re-export smoke check (9 intests/test_nn_scheduler_params_builder.py+ 1 intests/test_imports.py).NNOptimParams.builder()— variant-gated optimizer config (PR #44). Classic-GoF Builder reachable viaNNOptimParams.builder()returningNNOptimParamsBuilder(re-exported at top level asnnx.NNOptimParamsBuilder). Four optimizer variants —adam,adam_amsgrad,sgd,sgd_nesterov— plus three optional chained modifiers —grad_clip(norm),accumulate_grad(batches),param_groups(specs). The Adam variants take a PyTorch-nativebetas: tuple[float, float]kwarg which the Builder maps onto the dataclass'smomentumfield — the field name staysmomentumon-disk for back-compat, but the Builder API uses the PyTorch spelling. SGD variants keep the floatmomentum=kwarg. Existing direct-kwargNNOptimParams(name=Optims.ADAM, ...)ctor is untouched. Omit-when-defaultstate()invariant preserved automatically (Builder only forwards user-touched fields). 9 new tests intests/test_nn_optim_params_builder.py(8) +tests/test_imports.py(1) cover happy-path per variant, thebetas-→-momentumfield mapping,grad_clip/accumulate_grad/param_groupschaining + state-round-trip, and the top-level re-export.NNTransformerParams.builder()— LM-path config (PR #45). Classic-GoF Builder reachable viaNNTransformerParams.builder()returningNNTransformerParamsBuilder(re-exported at top level asnnx.NNTransformerParamsBuilder). Six fluent methods —vocab(size)(sets bothinput_dim+output_dim),layers(n, heads, d_model)(enforcesd_model % heads == 0at call-time),ffn(mult),context(max_seq_len, rope_base),dropout(attn, resid),tied_embeddings(value). The Builder hides the dead parent-NNParams kwargs (hidden_dims,activation,dropout_prob) that TransformerNN doesn't use, so the LM-path API reads in LM-path terms. Existing direct-kwargNNTransformerParams(...)ctor is untouched. Omit-when-defaultstate()invariant preserved automatically. 8 new tests intests/test_nn_transformer_params_builder.py(7) +tests/test_imports.py(1) cover happy-path with hidden-parent-kwargs, omit-when-default, thed_model % heads == 0call-time validation, all four chained modifiers (ffn/context+rope/dropout/tied_embeddings), and the top-level re-export.NNTrainerParams.builder()— composite multi-optim Builder (PR #46). Classic-GoF Builder reachable viaNNTrainerParams.builder()returningNNTrainerParamsBuilder(re-exported at top level asnnx.NNTrainerParamsBuilder). Composes the Plan 1+2 Builders:.optimizer(name, NNOptimParams)and.scheduler(name, NNSchedulerParams)register each entry under a user-chosen name..build()enforcesschedulers.keys() ⊆ optims.keys()at the Builder boundary with an actionable error naming the unknown scheduler key + listing the known optim names — todayNNTrainerParams.__post_init__enforces the same invariant only after the dataclass ctor runs, so the Builder surfaces it one stack-frame earlier. Plus chained modifiers.n_epochs,.seed,.save_phase_checkpoints,.train_loader,.val_loader,.extra_metrics. The Example 09 GAN recipe (two parallel optimizers with matching schedulers) collapses from ~30 lines to a single fluent expression. Purely additive; existing direct-kwargNNTrainerParams(optims={...}, schedulers={...})ctor untouched; on-diskstate()/from_state()round-trip unchanged. Omit-when-default invariant preserved (Builder only forwards the user-touched fields, and the empty_schedulersdict isn't passed to the dataclass kwargs when it's untouched, so the field stays at thedefault_factory=dictdefault). 10 new tests intests/test_nn_trainer_params_builder.py(9) +tests/test_imports.py(1) cover happy-path (minimal + GAN recipe), the omit-when-default invariant, the.build()-time key-subset rejection, the actionable error-message contract (names the typo + lists known optim names), build-without-any-optim, chained.train_loader/.seed/.extra_metrics, the full state() round-trip through the inner Plan-1/2 Builders, and the top-level re-export.LogitsChain+LogitsChain.builder()— power-user LM decoding (PR #48). New top-level typesnnx.LogitsChainandnnx.LogitsChainBuilder.LogitsChainis a thin frozen-dataclass wrapper aroundlist[LogitsProcessor]with an.apply(logits, token_history)method. The Builder chains.repetition_penalty(p),.top_k(k),.top_p(p),.temperature(t),.custom(processor)in any order;.build()sorts the canonical processors into NNx's canonical order (RepetitionPenalty → TopKFilter → TopPFilter → TemperatureScaling; temperature deliberately last — see the post-#54 truthfulness fix in the Fixed section above), with custom processors appended after.GenerativeNNModel.generate(...)gains a new optionallogits_chain: Optional[LogitsChain] = Nonekwarg — when provided, the inline chain construction fromtemperature/top_k/top_p/repetition_penaltykwargs is skipped. WhenNone(the default), behavior is unchanged. Purely additive; existinggenerate(temperature=0.8, top_k=50, ...)callers continue to work. 8 new tests: 6 pure-torch intests/test_logits_chain.py(empty-chain pass-through, single-processor, canonical-order enforcement, custom-processor append-after-canonical, multiple-calls-to-same-method overwrite contract,chain.applyequivalent toapply_chaindirect call) + 1 import smoke + 1 integration test intests/test_generative_nn_model.py(gated on thelmextra) that verifiesgenerate(logits_chain=..., seed=...)is reproducible across two calls.
Added — Month-1 cluster (PRs #32–#37)¶
- PEP 561
py.typedmarker (PR #32). Adds an emptysrc/nnx/py.typedand a[tool.setuptools.package-data]entry so the wheel ships it. Declares NNx as type-checked for downstreampyright/mypyconsumers — they now see the existing public-surface annotations (NNModel,NNParams, callbacks, enums) instead of treating every symbol asAny. No NNx-side typing change; the gain is entirely downstream. docs/comparison.mdpage (PR #33). "NNx vs Lightning / HF / fastai / Composer" honest, scope-explicit comparison: quick decision matrix, landscape map, capability-axis tables (training loop / distributed / PEFT / generation / diffusion / GNN / surgery / observability / Hub), when-to-use-what rule-of-thumb, and a "what NNx doesn't ship" call-out. Wired intomkdocs.ymlnav and linked from README §5.1.nnx.viz.gradient_flow(model)(PR #34). Per-layer L2 gradient-norm bar chart for training-loop diagnostics. Call afterloss.backward()and beforeoptimizer.zero_grad(); returns a PlotlyFigure. Frozen params and params withgrad is Noneare skipped. RaisesValueErrorwith a helpful message when no parameter has a populated gradient. Six new tests; doc paragraphs in concepts.md §12 (now "Six primitives") and api.md.nnx.lr_finder(model, train_loader, *, loss_fn, ...) -> LRFinderResult(PR #35). fastai-style exponential LR sweep (1e-7 → 10.0 over 100 iters by default) returning the suggested one-cyclemax_lrvia the Smith (2017) steepest-descent heuristic on EMA-smoothed loss, plus a Plotly figure of loss vs log(LR). Non-destructive: model state and training-mode are snapshotted before the sweep and restored on exit. Early-exits on divergence (loss > 4× min observed). Nine new tests covering return type, field shape, suggested_lr in range, weight restoration, three invalid-argument paths, and log-axis figure. Docs: concepts.md §13.1 + api.md.NNRun._repr_html_()(PR #36). Jupyter rich-display: when anNNRunis the last expression in a cell, it renders a config table (run.id, net, device, loss, dims, dropout, activation, n_epochs, optim + max_lr) plus a Plotly per-epoch metric chart (train_loss / val_loss / train_err / val_err — val traces appear only when validation data was present). Plotly is lazy-imported inside the chart helper so non-Jupyter cost is zero. Falls back to config-table-only whenself.idpsis None / empty. Epoch boundaries detected byidp.epoch_idxso train-only runs render correctly. Four new tests.- Six missing megamerge example scripts (PR #37). Closes PR #31's largest deferred item. New files:
19_prune_mnist.py(magnitude pruning at 50% sparsity + brief fine-tune),20_surgery_resnet.py(low-rank factorize a Linear at rank=8 + refinement),21_viz_attribute_xai.py(Captum attribution across 4 methods),22_dpo_tinystories.py(DPO with a deepcopied frozen reference policy on synthetic preference triples; before/after log-prob comparison showsΔ(chosen − rejected) > 0),23_born_again_distillation.py(iterated self-distillation across G=3 generations),24_feature_kd.py(FitNets-style feature distillation with one paired teacher → student layer). Each is CPU-runnable in under 2 minutes.examples/README.mdcatalog updated with four new sub-sections.
Fixed — Month-1 cluster follow-ups¶
Listed roughly chronologically by ship date; maintenance-pass follow-ups are grouped where their cluster landed rather than strictly interleaved.
NNRun.load/NNCheckpoint.loadreject path-traversal run ids (post-PR-#41 overnight-maintenance pass). BothNNRun.load(id=...)andNNCheckpoint.load(run=...)accept a public string identifier and join it directly into a path underruns/<id>/.... Internal callers always pass the md5 hex ofNNRun.state()(32 hex chars; always safe), but a public caller passing"../../etc/passwd"would have escaped the runs root:os.path.join("runs", "../../etc/passwd")resolves toetc/passwd. Sensitive files sitting next to the working directory would be readable on the next.load(...)call. Mirrors the spirit of PR #40'syaml.safe_loadfix: the file is normally application-written, but defense-in-depth says never trust the input to escape the API boundary. Added_validate_run_id()insrc/nnx/nn/params/nn_run.pythat rejects path separators,..,., embedded nulls, and empty / non-string ids; called fromNNRun.loadand from_checkpoint_path(the single path-construction siteNNCheckpoint.save/.load/.load_optimizer_stateall funnel through). New regression teststest_r3_nnrun_load_rejects_path_traversal_run_ids(8 traversal-shaped inputs) andtest_r3_nnrun_load_accepts_normal_md5_id(happy-path md5 hex still passes).artifacts/directory ignored (post-PR-#41 overnight-maintenance pass). Three examples write to top-levelartifacts/subdirectories (11_tinystories_lm.py→artifacts/tinystories_lm/,17_export_transformer_to_gguf.py→artifacts/lm_export/,18_publish_to_ollama.py→artifacts/ollama_bundle/). Without ignoring the directory, anyone who ran one of these examples ended up with.ggufbinaries /tokenizer.json/Modelfileshowing as untracked ingit status— one carelessgit add -Aaway from sweeping them into the repo. Addedartifacts/to.gitignoreunder the existing "nnx training + example artifacts" block (retitled from "training artifacts" to reflect the expanded scope) alongsideruns/andtb_logs/. Comment block names the three examples that write there so a future maintainer can trace the entry to its callers.pyproject.tomlPygments pin (PR #38) — Pygments 2.20.0 brokepymdownx.highlight(afilename=Nonepropagates intoHtmlFormatter.__init__and crashes withAttributeError: 'NoneType' object has no attribute 'replace'), makingmkdocs build --strictfail on every docs page containing a fenced Python code block or a mkdocstrings classsourceblock. PinnedPygments<2.20in the[docs]extra; CI install line picks it up automatically.- Examples 20 + 24 seed-state bug (PR #38) —
torch.manual_seed(0)inside_make_data()silently overrode the caller'sset_seed(42)frommain(). PR #37's review caught the same bug in examples 19 / 21 / 23 and fixed those; the fix was missed for 20 / 24. Now removed; examples still complete end-to-end on CPU. nnx.lr_findercorrectness pass (PR #39). Three correctness issues deferred from PR #38's audit, all fixed: (1) smoothed-min divergence guard — early-exit now compares an EMA-smoothed loss against an EMA-smoothed running minimum (matches fastai'slr_find), so an anomalously low first-batch loss no longer ends the sweep before the descent region is reached; (2) short-sweep fallback — when fewer than 5 points are recorded, suggested LR is the LR at the minimum observed loss, notstart_lr(which would have been the worst possiblemax_lr); (3) monotonically-rising-loss fallback — when no descent region exists at all, the slope-based heuristic falls back to the min-observed-loss LR rather than returning the steepest-positive-slope index. Also fixes a degenerate empty-traceadd_vlinecrash on extremely short sweeps.NNRun._repr_html_no longer accepts a staleidpsarg. Six new regression tests intests/test_lr_finder.py.nnx.interopattribute-access regression (post-#39 maintenance pass). README §1.2 advertisesnnx.interop.write_gguf(...)andnnx.interop.export_ollama_modelfileas the post-import nnxaccess pattern, matching every other subpackage (nnx.diffusion,nnx.peft,nnx.quantize, …). The package-levelsrc/nnx/__init__.pywas missinginteropfrom its eager-bind list, so a plainimport nnxlefthasattr(nnx, "interop")False and the README's documented call shape raisedAttributeErroruntil callers added an explicitimport nnx.interop. Fixed by addinginteropto thefrom . import …line (the interop subpackage's own docstring already promises that the plain import is safe whenggufisn't installed, since the dep is lazy-imported insidewrite_gguf/export_ollama_modelfile). New regression testtest_subpackages_attribute_accessible_after_plain_importassertshasattr(nnx, name)for every advertised subpackage so future drift is caught loudly.NNRun.save/NNRun.loaduse safe YAML APIs (post-#39 maintenance pass).NNRun.loadpreviously usedyaml.load(f, Loader=yaml.FullLoader), which underPyYAML>=5no longer permits arbitrary-code tags by default but still allows instantiation of arbitrary Python objects via tags like!!python/object/.... A tampered or attacker-suppliedruns/<id>/run.yamlcould escalate a filesystem-write primitive into arbitrary-object construction at load time. Switched toyaml.safe_load(matching the long-standingsafe_dump/safe_loadpair the siblingmetadata.yamlalready used — seeseeding.py's "yaml.safe_load-compatible" comment). Also tightenedNNRun.savetoyaml.safe_dump, so any futurestate()change that smuggles in a non-primitive type fails loudly at write-time instead of producing arun.yamlthat no longer round-trips. State is a plain dict of primitive types, so the change is a behavior-preserving drop-in; existingNNRun.save/NNRun.loadround-trip tests across the suite continue to pass. New regression testtest_r3_nnrun_load_rejects_python_object_tagsasserts a poisoned!!python/name:os.systemtag raisesyaml.YAMLError.- Example 16 seed-state bug (post-#39 maintenance pass) — same anti-pattern as 19–24:
_make_synthetic_loaderinexamples/16_ijepa_cifar10.pyopened withtorch.manual_seed(0), silently overriding the caller'sset_seed(0)frommain(). Today both values happen to be0, so the override is observable only when a future maintainer editsmain()to choose a different seed and the dataset stubbornly stays the same. Removed; added the same "no torch.manual_seed here — caller does it" comment that examples 20 / 24 already carry, so the contract is visible at the helper site. nnx.embeddings.pair_collateis now a public re-export (post-#39 maintenance pass).docs/embeddings.mddocumentspair_collatetwice as the recommendedDataLoader.collate_fnforContrastiveTextDataset, and the symbol itself has no leading underscore — so it's semantically public. But thennx.embeddingssubpackage's__init__.pyomitted it from bothfrom .contrastive_trainer import …and__all__, so users following the docs had to reach into the implementation path (from nnx.embeddings.contrastive_trainer import pair_collate) — a fragile coupling that would break the first time the trainer module is restructured. Added to__all__alongside the rest of the public surface and updated the test import to use the public path (from nnx.embeddings import pair_collate). The intentional private helper_is_sentence_transformerkeeps its submodule import path, with an explanatory comment noting the asymmetry.- Cross-platform text I/O is now utf-8-explicit (post-#39 maintenance pass). Five
open(...)text-mode call sites — three insrc/nnx/nn/params/nn_run.py(atomic write, POINTER.txt read,run.yamlsafe_load) and two insrc/nnx/nn/nn_model.py(_HUB_CONFIG_FILENAMEwrite and read on the Hub round-trip path) — relied on Python's locale-default text encoding. On Linux / macOS that's utf-8 today; on Windows pre-PEP-686 (so anything before Python 3.15 on the default config) it's the locale-specific code page (cp1252in many cases), which would silently mis-encode a unicode tokenizer path or a non-ASCII run id round-tripped throughstate(). Pinned all five toencoding="utf-8"with a one-line rationale at the most central site. No behavior change on Linux / macOS; closes the Windows-locale corruption window. LICENSEcopyright year + holder name (post-#39 maintenance pass). The MIT license header readCopyright (c) 2023 Kaveh, predating the repo (first commit2026-05-18) and using a first-name-only holder string. Updated toCopyright (c) 2026 Kaveh Razavito match the actual repo-init year and thepyproject.toml[project] authorsentry. No content / license-terms change.pyproject.tomlPyPI keywords catch up to the megamerge subsystems (post-#39 maintenance pass). The[project] keywordslist still reflected the pre-megamerge thekaveh/ml-extraction scope (pytorch,deep-learning,graph-neural-networks,training,checkpointing, …) and missed every major subsystem PRs #29–#37 shipped as a first-class entry point: quantization, language modeling, LoRA / PEFT, diffusion, knowledge distillation, embeddings / RAG, pruning, model surgery, ONNX export, GGUF interop. Added each as a keyword so PyPI search surfaces NNx for users looking for any of those topics. Verified the resultingKeywords:header in the installed wheel metadata.- Inference helpers (
predict,evaluate,generate,sample,embed_texts) now restoremodel.traininginstead of stranding it in.eval()(post-PR-#40 overnight-maintenance pass). Five inference-shaped helpers acrossNNModel.predict,NNModel.evaluate,GenerativeNNModel.generate,nnx.diffusion.sample, andnnx.embeddings.embed_textsall switched the underlyingnn.Moduleto.eval()mode (needed for correct BatchNorm / Dropout semantics during inference) but never restored the caller's prior training-mode state. The codebase already had two non-destructive precedents (nnx.viz.activation_mapand the post-PR-#39nnx.lr_finder); the inference helpers were the odd ones out. Common train → evaluate → train-more / train → predict → train-more loops silently disabled Dropout masking and BatchNorm running-stats updates on the next training step unless the caller remembered to callmodel.net.train()themselves. Wrapped each helper's body intry: ... finally: model.net.train() if was_training else None. Newtests/test_inference_helpers_preserve_training_mode.pyships 9 regression tests covering both train→inference→train AND eval→inference→eval round-trips per helper (paired*_restores_*/*_preserves_eval_mode_callercases for predict / evaluate / diffusion.sample / embed_texts), plus apredict()exception path that verifies thefinallyrestore fires even when the body raises.GenerativeNNModel.generate's equivalent round-trip is intests/test_generative_nn_model.py::test_generate_restores_training_mode_after_callbecause that file already carries thepytest.importorskip("tokenizers")guard needed for the LM path. nnx.lr_findernon-destructive contract held only on the happy path (post-PR-#40 overnight-maintenance pass). The docstring promises "the model's initial weights are snapshotted before the sweep starts and restored on exit" — but the restoration ran after the for-loop, not inside atry/finally. Any exception during the sweep (user-suppliedloss_fnraising,model(X)crashing on a malformed batch,loss.backward()failing on a NaN gradient) skipped the restore and left the caller's model permanently mutated: weights stuck at whatever the optimizer's mid-sweep updates produced, andmodel.trainingleft True even if the caller passed aneval()-mode model in. Wrapped the loop body intry: ... finally: model.load_state_dict(initial_state); model.train(was_training). New regression testtest_lr_finder_restores_state_after_exception_in_loss_fnpasses an eval-mode model + a flakyloss_fnthat raises on iteration 4, then asserts both the weights and the training-mode flag are restored despite the mid-sweep crash. Empirically verified against the pre-fix code path (withouttry/finally, both invariants fail).nnx.generation.sample_next_tokenNaN guard (post-PR-#40 overnight-maintenance pass). The degenerate-probability fallback in the LM sampler usedprobs.sum().item() == 0.0to detect rows that couldn't drivetorch.multinomial, falling back toargmax(logits)instead. The check is wrong for one residual case: if upstream produces NaN logits (e.g. a divergent KV-cache decoding step),softmax(NaN)propagates to all-NaN probs andprobs.sum()is NaN — andNaN.item() == 0.0is False, so the guard misses it. The user-visible failure wasRuntimeError: probability tensor contains either inf, nan or element < 0fromtorch.multinomial. (The two prior guards in the same function —+infearly-return andtorch.isinf(logits).all()argmax fallback — don't cover this case:isinf(NaN)is False, so a row mixing NaN with finite values reaches softmax intact.) Switched the check tonot torch.isfinite(total) or total.item() <= 0.0, which catches NaN,+/-inf, and zero/negative sums. Newtests/test_generation_sampling.pyships 7 unit tests (+infgreedy short-circuit, all--inffallback, NaN-in-logits regression, normal-draw shape, generator reproducibility, bad-shape rejection, underflow edge); the file is shape-test-only so it runs on every CI matrix row regardless of thelmextra.NNTransformerParamsBuilderactivation default matches the parentNNParamsdefault (PR #50).NNTransformerParamsBuilder.build()hardcodedactivation=Activations.RELU, but the parentNNParamsdefault isActivations.LEAKY_RELU. The two construction paths — Builder vs direct-kwarg ctor — therefore produced differentstate()dicts and differentrun.idhashes for what users would call "the same config", silently breaking the omit-when-default invariant for everyone using the LM-path Builder. Caught by the post-#49 overnight-maintenance Pass 1 audit; corrected toActivations.LEAKY_RELUso Builder-built transformers hash identically to direct-kwarg ones. New regression testtest_builder_state_equals_direct_ctor_with_parent_defaultsasserts the two construction paths produce equal dataclasses AND equalstate()dicts. Migration note: any priorrun.idhash that came fromNNTransformerParams.builder()...build()(rather than direct-kwargNNTransformerParams(...)) will shift to a new id; the on-disk run.yaml continues to load correctly viaNNTransformerParams.from_state(...).NNOptimParamsBuildermodifier-before-variant chains no longer silently drop the modifier (PR #50).NNOptimParamsBuilder.adam()/.adam_amsgrad()/.sgd()/.sgd_nesterov()didself._fields = {...}(full replace), which silently wiped any prior.grad_clip(N)/.accumulate_grad(N)/.param_groups(...)call. The documented chain order was variant-first-then-modifiers, but the fluent API didn't enforce it — modifier-before-variant chains compiled successfully and silently dropped the modifier. Refactored via a_set_variant()helper that drops only the variant-keyed fields (name/max_lr/momentum/weight_decay) before applying the new variant, so any modifier-set keys (grad_clip_norm/accumulate_grad_batches/param_groups) survive. Last-variant-wins (the existing documented contract) is preserved. New regression tests cover modifier-before-variant survival across all four variant methods.- Six stale
pip install 'nnx[X]'references swept to'thekaveh-nnx[X]'(PR #50). PR #49's CHANGELOG promised the install-name rename was complete across all docs/examples/source, but the post-#49 audit caught six stragglers — three example module docstrings and threesrc/nnx/interop/*lazy-import error strings — that still showed the squatted barennxname. Updated; closes the gap PR #49 promised. NNTransformerParamsBuilder.tied_embeddings(True)/NNTrainerParamsBuilder.save_phase_checkpoints(True)(PR #50). Both setters originally gated withif value is True/False:to avoid storing the dataclass default — but that broke the documented "last call wins" contract: a prior.tied_embeddings(False)followed by.tied_embeddings(True)left the dataclass atFalsebecause the True call was a silent no-op. Fixed by storing unconditionally; the dataclass's omit-when-defaultstate()handles run.id stability automatically (the convention every other Builder already followed). Regression teststest_builder_tied_embeddings_true_after_false_overrides_to_trueand the analogoussave_phase_checkpointstest added.env_snapshot()['nnx']now queries the renamed distribution (post-PR-#50 overnight-maintenance pass).src/nnx/seeding.py:_nnx_version()still calledimportlib.metadata.version("nnx")after the PR #49 PyPI rename tothekaveh-nnx. On any clean install of the renamed package the lookup raisedPackageNotFoundError, was swallowed by the broadexcept Exception:block, andmetadata.yamlsilently recordednnx: null. metadata.yaml's job is reproducibility — a silentNonedefeats the whole purpose of the snapshot for the most useful field. Mirrors the lookupnnx.__version__already uses (src/nnx/__init__.py). New regression testtest_v3_env_snapshot_nnx_version_is_resolvableassertssnap['nnx'] is not None and snap['nnx'] == nnx.__version__so future drift between the two version lookups is caught loudly.NNModel.to_onnxnow restoresmodel.net.traininginstead of stranding the caller in.eval()(post-PR-#50 overnight-maintenance pass). The post-PR-#40 non-destructive-helper pass fixed five inference helpers (predict / evaluate / generate / diffusion.sample / embed_texts) but leftto_onnxas the lone exception — it calledself.net.eval()(correct for tracing) with no paired restore. A user doingmodel.train(...) → model.to_onnx(...) → model.train(...)silently disabled Dropout / BatchNorm-running-stats on the secondtrain()call. Wrapped the export body intry: ... finally: if was_training: self.net.train(), matching the shapennx.viz.netron_exportalready used. Two new regression tests intests/test_to_onnx_inputs.pycover the train→to_onnx→train round-trip AND the eval-mode caller preservation.NNTransformerParamsBuilder.dropout()/.context()honor "last call wins" (post-PR-#50 overnight-maintenance pass). Two setters still carried theif value is not <default>:gating anti-pattern that PR #50 cleaned up everywhere else:.dropout()gated onif attn != 0.0:/if resid != 0.0:, so.dropout(attn=0.5).dropout(attn=0.0)leftattn_dropoutat 0.5;.context()gated onif rope_base is not None:, so.context(rope_base=500000.0).context(max_seq_len=2048)left the prior 500000.0 in place. Fixed by storing unconditionally on.dropout()(Builder defaults match dataclass defaults; omit-when-default instate()handles run.id stability) and switching.context()to a delete-or-store pattern (pass-throughNonedrops any prior override so the dataclass default governs at build time). Two new regression tests cover both paths.nnx.interop.export_ollama_modelfilewrites Modelfile with explicitencoding="utf-8"(post-PR-#50 overnight-maintenance pass). The post-PR-#39 utf-8 audit (5open(...)call sites fixed) used agrep "open("sweep that didn't catch a later-addedPath.write_text(...)insrc/nnx/interop/ollama.py. A non-ASCII SYSTEM prompt or TEMPLATE (Asian-language fine-tune, emoji prompt) silently mojibake-encoded on Windows pre-PEP-686 where the platform default iscp1252. Addedencoding="utf-8"to thewrite_textcall. New regression testtest_export_ollama_modelfile_writes_utf8_for_non_ascii_systemwrites a mixed-script Japanese + accented Latin + emoji SYSTEM prompt and asserts a clean utf-8 round-trip viaread_bytes().decode("utf-8").- Example 13 + 12 docstring "Requires" + examples/README.md install matrix sync (post-PR-#50 overnight-maintenance pass). By the maintainers' convention, every example using a
[project.optional-dependencies]extra must declare it in the module docstring's "Requires" paragraph.examples/13_train_domain_embedder.pyimportsfaiss+ referencessentence_transformersbut had no "Requires" paragraph for[embeddings].examples/12_quantize_int8.py's Phase-5 step callsmodel_q.to_onnx(...)— the legacy TorchScript exporter needs theonnxPyPI package — but the install line said onlypip install thekaveh-nnx[quantize].examples/README.md's install matrix listed15_qat_classifier.pyonly under[quantize]despite its Phase-5 dynamo export needing[onnx-dynamo]. All three sync'd. - Phase-checkpoint quartile logic consolidated into a single
phase_tag()helper (post-PR-#51 overnight-maintenance pass). The FIRST/Q1/Q2/Q3 epoch-boundary branch chain (if idx_epoch == 0: ... elif idx_epoch == int(n_epochs * k/4) - 1: ...fork = 1, 2, 3) was duplicated verbatim acrosssrc/nnx/nn/nn_model.py:968-975(theNNModel.trainwrite path) andsrc/nnx/trainer/trainer.py:393-401(the multi-optimTrainer.trainwrite path). A future fix to the phase-boundary semantics (e.g., handling the small-n_epochscollision documented below) would have had to be made in two places. Extractedphase_tag(idx_epoch, n_epochs) -> Optional[Checkpoints]tosrc/nnx/nn/enum/checkpoints.py; both call sites now delegate. Pure refactor — no behavior change. Helper docstring documents the small-n_epochscaveat explicitly: forn_epochs in [4, 5, 6, 7]the Q1 index collides with FIRST (int(n_epochs * 1/4) - 1 == 0) and Q1 is silently never written; forn_epochs in [1, 2, 3]Q2/Q3 also miss. Changing the math would shift run.id-relevant trajectories for callers already relying on the current schedule, so the silent-skip is preserved as a documented trade-off rather than fixed. Newtests/test_phase_tag.pyships 13 parametrized unit tests locking the schedule in: FIRST-at-zero invariant, canonical n_epochs=8 quartiles, "every quartile reachable for n_epochs >= 6" parametrize matrix, "Q1 dropped for n_epochs in [1,5]" parametrize matrix, and non-boundary-returns-None sanity. NNTrainerParamsBuilder.build()surfacesn_epochsmissing at the Builder boundary (post-PR-#51 overnight-maintenance pass). CallingNNTrainerParams.builder().optimizer("main", ...).build()previously raisedTypeError: NNTrainerParams.__init__() missing 1 required keyword-only argument: 'n_epochs'— the bare dataclass error names nothing actionable. The Builder already catches the harderschedulers.keys() ⊆ optims.keys()invariant at the.build()boundary with an actionable error (the Builders' pre-check); the missing-n_epochscase fell through to the dataclass ctor, which was the one error path that did not raise a Builder-level error naming the fix. Added a pre-construct check that raisesValueError("NNTrainerParamsBuilder.n_epochs() must be called before .build() — n_epochs has no meaningful default. Example: .n_epochs(50).optimizer('main', NNOptimParams(...)).build()"). New regression testtest_builder_rejects_build_without_n_epochsasserts the error message names.n_epochs()(the method to call), not the dataclass field.- PyPI keywords drift caught up (post-PR-#51 overnight-maintenance pass). Per the maintainers' PyPI-keyword convention, every major subsystem shipped as a first-class
nnx.*entry point should appear inpyproject.toml[project] keywordsfor PyPI search discoverability. Three subsystems landed without matching keywords:learning-rate-finder(PR #35'snnx.lr_finder),multi-optimizer(PR #46's compositennx.trainer.Trainer+NNTrainerParams), andbuilder-pattern(the cross-cutting<Class>.builder()fluent API on every params dataclass, PRs #43–#46 + #48). Added all three. Note: the GitHub repo's About-section Topics list (curated 20-max subset for browse discovery) was independently updated post-merge; pyproject keywords are the broader feature inventory and don't need 1:1 parity. tests/test_imports.pyregresses against_PackageNotFoundErrornamespace re-leak (post-PR-#51 overnight-maintenance pass). PR #51 underscore-aliasedimportlib.metadata.PackageNotFoundError → _PackageNotFoundErrorinsrc/nnx/__init__.pyto keep the importlib exception out of thennx.*public surface, but explicitly shipped no regression test ("pure API hygiene"). A future maintainer dropping the alias to "simplify the imports" would silently re-introduce the leak; pre-emption: addedtest_packagenotfounderror_is_not_a_public_nnx_symbolassertingnot hasattr(nnx, "PackageNotFoundError")with the underscore-alias fix in the error message. Same pattern applies to any future implementation-detail import that lives in the top-levelnnx/__init__.py.NNParams.n_headsomit-when-default invariant gets an explicit on-disk-shape assertion (post-PR-#51 overnight-maintenance pass). Every other params class (NNTrainerParams,NNTransformerParams,NNOptimParams,NNSchedulerParams,NNTrainParams,NNModelParams) has an explicitassert "<field>" not in obj.state()test pinning the omit-when-defaultstate()contract.NNParams.n_headswas the lone gap — the existingtest_nn_params_round_trip(passingn_heads=None) andtest_nn_params_round_trip_with_n_heads(passingn_heads=4) cover behavior viafrom_state(obj.state()) == objbut neither checks the on-disk key-presence shape directly. Added pairedtest_nn_params_state_omits_n_heads_when_noneandtest_nn_params_state_emits_n_heads_when_setto lock the run.id-stability contract for the FFN path (wheren_headsis always None).examples/06_finetune_with_layer_freezing.pyseeding convention drift fixed (post-PR-#51 overnight-maintenance pass). Per the maintainers' example-seeding convention, the helper-leveltorch.manual_seed(...)anti-pattern silently overrides the caller's seed; the established convention (examples 16 / 20 / 24) is that helpers carry an explicit# No torch.manual_seed here — the caller does set_seed(...) in main()comment andmain()callsnnx.set_seed(...)once before each phase. Example 06 had two violations:_make_loaders(seed)calledtorch.manual_seed(seed)(torch-only, not numpy/python — partial seeding), and_make_model(seed)calledset_seed(seed)(full seeding inside a helper, hiding the seed-management responsibility frommain()). Both helpers now consume the current RNG state;main()callsset_seed(0)before Phase-1 model+loader construction,set_seed(1)before Phase-2 model construction (different init), andset_seed(42)before the Phase-2 loader (distribution B). The two-phase distinct-distribution semantics are preserved.text_contrastive_train_step_factorypromoted to top-levelnnx.*+ example 02 seeding moved tomain()(post-PR-#53 overnight-maintenance pass). Two small-surface ergonomics + convention catches. (1)nnxexposes 11*_train_step_factoryfunctions as the canonical extension point for custom training paradigms (KD / feature-KD / SimCLR / Mixup / CutMix / MoE / JEPA / DPO / QAT / Diffusion + Trainer-style multi-optim). 10 of them were at the top level (nnx.kd_train_step_factory, etc.);text_contrastive_train_step_factorywas the lone outlier reachable only viannx.embeddings.*. Asymmetry broke thennx.<TAB>discoverability expectation (search for*_train_step_factory, find all paradigms). Promoted with a placement comment explaining that the high-leveltrain_contrastiveand FAISS-export helpers intentionally stay undernnx.embeddings.*(the top-level surface stays focused on train-step entry points). (2)examples/02_resume_training.py:33hadset_seed(7)inside the_make_model_and_loader()helper — the helper-level seed-override anti-pattern (caught repeatedly before across examples 06/16/19/20/21/23/24). Moved tomain()where it's called twice (once per round) so the reproducibility contract is visible at the entry point. Helper carries the canonical "No set_seed here — the caller does it in main()" comment. Behavior preserved: both rounds still build the same initial weights (Round 2's get overwritten by load_state_dict on resume), and the DataLoader shuffle order stays pinned for an apples-to-apples training trajectory.- Reproducibility hardening:
NNDataset+NNTabularDatasetseeded splits,set_seedsetsPYTHONHASHSEED,NNRun.savepinssort_keys=True(post-PR-#53 overnight-maintenance pass). Three reproducibility gaps caught by the new determinism-audit angle. (1)NNDataset.__post_init__andNNTabularDataset.__post_init__calledtorch.utils.data.random_split(...)WITHOUT agenerator=arg. The split consumed the global torch RNG, so two runs with identicalset_seed(42)could diverge if any intervening code (model layer init, weight registration, anything that calls a torch random op) consumed RNG state between the seed call and the dataset construction. The siblingNNPreferenceDatasetalready had the seeded-split contract (PR #42 —seed: Optional[int]field +gen = torch.Generator(); gen.manual_seed(self.seed); random_split(..., generator=gen)). Both holdouts now match: addedseed: Optional[int] = Nonefield (the default was intended to keep pre-fix global-RNG behavior for back-compat, but shipped passing a fresh fixed-seedtorch.Generator()instead — see the post-#54 fix above, which restored the documented global-RNG fallback); when set, builds a deterministic Generator and passes torandom_split. New regression testtest_f8_tabular_dataset_seeded_split_is_deterministicasserts (a) same seed → identical train/val/test ids, (b) different seed → different ids (200-row sanity check; collision probability astronomically small). (2)set_seed(seed)previously seededrandom+numpy.random+torch+torch.cuda+ cuDNN deterministic mode but did NOT touchos.environ["PYTHONHASHSEED"]. DataLoader workers using thespawnstart method (default on Windows + macOS/Py3.8+) re-randomize their dict/set hash seed on every spawn — any worker code that iterates a dict/set populated in-worker could differ between runs even when every other RNG is seeded. Now writesPYTHONHASHSEED=<seed>so spawned children inherit. Docstring documents the caveat that the current Python interpreter's hash state was fixed at startup and is NOT affected by this assignment (only spawned subprocesses); for full hash determinism in the current process, set the env var in the shell before invoking Python. (3)NNRun.savecalledyaml.safe_dump(self.state())andyaml.safe_dump(env_snapshot())withoutsort_keys— relied on PyYAML's post-5.1 default ofTrue. Pinnedsort_keys=Trueexplicitly so the on-disk YAML stays alphabetically stable across PyYAML major-version bumps that might change the default again.run.idismd5(str(state()))and unaffected (Python 3.7+ dicts preserve insertion order); this is purely the on-disk YAML shape downstream tooling diffs against. src/nnx/nn/__init__.pygets a docstring and explicit empty__all__(post-PR-#53 overnight-maintenance pass). Thennsubpackage's__init__.pywas zero bytes — every sibling subpackage (embeddings,peft,paradigms,viz, …) carries a top-level docstring describing its surface. Added a docstring naming the seven internal modules (params,net,dataset,enum,callbacks,nn_model+generative_nn_model,moe), with the intentional convention that users reach the public surface via the top-levelnnxnamespace (from nnx import NNModel, NNParams, …) not viannx.nn.*deep imports.__all__: list[str] = []makesfrom nnx.nn import *a deliberate no-op — discouraging callers from bypassing the curated top-level surface. Pure documentation; no behavior change.- Idiom + dataclass consistency catch-up (post-PR-#53 overnight-maintenance pass). Three small-surface consistency drifts caught by the Python-idiom audit. (1)
LRFinderResult(src/nnx/lr_finder.py:27) was the lone params-shaped dataclass missing@dataclass(frozen=True, kw_only=True, slots=True)— every sibling result/params class (NNIterationDataPoint,NNEvaluationDataPoint,LoadPretrainedResult, etc.) carries the full triple. Single call site at line 185 already uses kwarg-only construction; tests don't mutate the result; safe to upgrade. (2) Eight.pyfiles with real type hints were missingfrom __future__ import annotations(97/111 files had it; the 14 outliers split into 6 truly-empty__init__.pyfiles that don't need it and 8 type-hint-carrying files that should). Added tovis_utils.py,nn/net/feed_fwd_nn.py,nn/net/graph_conv_nn.py,nn/net/graph_att_nn.py,nn/net/graph_sage_nn.py,nn/dataset/nn_dataset.py,nn/dataset/nn_dataset_base.py,nn/dataset/nn_graph_dataset.py. Cleaned up two"FeedFwdNN"quoted forward refs that ruffUP037flagged once the future import lifted the need. (3)Raises:blocks added to all four Builder.build()method docstrings (NNOptimParamsBuilder/NNSchedulerParamsBuilder/NNTransformerParamsBuilder/NNTrainerParamsBuilder) — the Builders' pre-checkValueErrorcontract was previously documented only in prose, leaving Sphinx / IDE tooltips without the canonical exception-doc form that 10+ sibling sites already use. - Public-API surface:
nnx.trainer.NNTrainerParamsBuildersubpackage re-export + 8 missing subpackages added tonnx.__all__(post-PR-#52 overnight-maintenance pass). Two cross-module API drift issues caught by the round-N+1 hygiene scan. First:from nnx.trainer import NNTrainerParamsBuilderraisedImportError. The top-levelnnx.NNTrainerParamsBuilderworked becausesrc/nnx/__init__.pyimported it viafrom .trainer.params_builder import ..., bypassing the subpackage's own__all__. Every other Builder supports thefrom <subpackage> import <Builder>convention (from nnx.peft import LoRALinearetc.); the trainer subpackage's__init__.pywas the lone gap. Addedfrom .params_builder import NNTrainerParamsBuilder+ the matching__all__entry. Second:nnx.__all__previously listed only four subpackages (viz/embeddings/interop/prune); the other eight specialization subpackages (peft/diffusion/finetune/generation/paradigms/quantize/surgery/trainer) were attribute-accessible (via Python's side-effect attribute binding onfrom .X import Y) but absent from the documented public surface that IDEs, doc generators, Sphinx autosummary, andfrom nnx import *all read. All 12 subpackages now consistently listed in__all__. New regression tests intests/test_imports.py:test_nn_trainer_params_builder_importable_from_subpackagelocks the subpackage-level import contract;test_subpackages_appear_in_top_level_alllocks the__all__membership for all 12 subpackages so future additions can't silently drift. NNParams.dimsproperty gains type-narrowingassert(post-PR-#52 overnight-maintenance pass)._dims: Optional[list[int]]is set unconditionally in__post_init__viaobject.__setattr__, so theOptionalis a dataclass artifact (slotted frozen subclasses can't take a non-Optionalinit=Falsefield withoutdefault_factory). Pyright can't model__post_init__-set fields and flagged thedimsproperty as returningOptional[list[int]]where the declared type islist[int]. Added a one-lineassert self._dims is not Nonefor both pyright narrowing and runtime sanity (the contract thatdimsis always available after__post_init__). Surgical fix from the post-#52 pyright-triage audit; one of two-to-three truly fixable items the audit picked out from the 89 pre-existing pyright errors that CI tolerates.seeding.pygit-helper exception rationale comments (post-PR-#52 overnight-maintenance pass). Per maintainer review, everyexcept Exception:insrc/nnx/should document its rationale inline._nnx_versionalready carried a detailed comment (PR #51); the two sibling helpers_git_commitand_git_dirtyswallowed their broad exceptions silently. Added one-line comments above each except:env_snapshot()is opportunistic — the caller may not be in a git repo (CI tarball install, fresh PyPI install, tempdir runs),gitmay not be onPATH, the 2-second timeout may fire, or the subprocess may crash.metadata.yamlomits the field rather than surfacing an exception. Pure documentation; no behavior change.- PEFT
load_*_weightssource-resolution consolidated into a shared_resolve_source_to_state_dicthelper (post-PR-#52 overnight-maintenance pass). Every PEFT adapter'sload_<adapter>_weights(module, source)function accepted either a filesystem path or a state-dict and resolved it to a plain dict before applying adapter-specific key filtering. The resolution step was duplicated verbatim acrossload_lora_weights/load_ia3_weights/load_prompt_weights/load_prefix_weightsand carried the security-criticalweights_only=Trueinvariant on thetorch.loadcall. Four sites meant a future tightening (or relaxation under documented context) would have to land in four places — exactly the kind of duplication a round-3 merge audit catches. Extracted_resolve_source_to_state_dict(source, fn_name)to a new private modulesrc/nnx/peft/_source.py; the four callers each shed ~7 lines and gained a single helper call. Thenn.finetune.loading.load_pretrainedcompanion intentionally does NOT consume this helper — its surface is wider (also acceptsnn.Module, usesmap_location="cpu"). All 70 PEFT tests pass; the existingmatch="path or dict"TypeError regressions intest_peft_lora.py/test_peft_ia3.pystill lock the error message format, which the helper preserves verbatim. - Builder-boundary pre-check applied uniformly to
NNOptimParamsBuilder/NNSchedulerParamsBuilder/NNTransformerParamsBuilder(post-PR-#52 overnight-maintenance pass). PR #52 established the Builder-boundary pre-check onNNTrainerParamsBuilder—.build()pre-empts a missing-required-no-default field with an actionable Builder-levelValueErrornaming the Builder method to call (.n_epochs(...)), not the dataclass field. The three sibling builders were the lone holdouts, each surfacing the dataclass's bareTypeError: missing N required keyword-only argumentsand forcing the user to translate dataclass field names back to Builder method names. Now:NNOptimParamsBuilder.build()raisesValueError("NNOptimParamsBuilder: call one of .adam(...) / .adam_amsgrad(...) / .sgd(...) / .sgd_nesterov(...) ...")if no variant was selected;NNSchedulerParamsBuilder.build()raises the analogousValueErrornaming its five variants;NNTransformerParamsBuilder.build()walks the missing setters (.vocab(size=...)/.layers(n=..., heads=..., d_model=...)/.context(max_seq_len=...)) and emits a precise list of what's missing. Each builder's existingtest_builder_build_without_variant_raises/test_builder_build_without_vocab_raisesregression updated to assert the newValueErrormessage format and lock the Builder-method-naming contract. NNTransformerParamsjoins the centralizedtest_params_round_trip.pycontract suite (post-PR-#52 overnight-maintenance pass). Per the omit-when-defaultstate()invariant, every params class withstate()/from_state()should have both a round-trip test and an explicitassert "<field>" not in state()test in the centralizedtests/test_params_round_trip.py(the official enforcement mechanism).NNTransformerParamswas the lone gap — its omit-when-default coverage lived only intest_nn_transformer_params_builder.py(domain-focused), missing from the central suite that catches integration drift. Added three tests:test_nn_transformer_params_round_trip_defaults(round-trip with every optional knob at default),test_nn_transformer_params_round_trip_with_overrides(round-trip with non-defaults sofrom_statehonors them), andtest_nn_transformer_params_state_omits_defaults(explicit on-disk-shape assertion forffn_mult/rope_base/tie_embeddings/attn_dropout/resid_dropout).examples/README.mdinstall matrix completes example 18's[lm]coverage (post-PR-#51 overnight-maintenance pass).examples/18_publish_to_ollama.pyusesNNTokenizerParams+train_bpe(HuggingFacetokenizers) — its own module docstring correctly sayspip install 'thekaveh-nnx[gguf-write,lm]'— butexamples/README.mdlisted it only under the[gguf-write]line. A user following only the README's install matrix and runningpip install thekaveh-nnx[gguf-write]would have hit anImportErrorattrain_bpe. Added 18 to the[lm]line, mirroring the dual-list pattern already used for example 12 (under[onnx]and[quantize]) and example 15 (under[quantize]and[onnx-dynamo]).
Expansion megamerge details (PR #29)¶
This release integrates 20 sub-projects consolidated on 2026-05-28: HuggingFace Hub interop (safetensors + PyTorchModelHubMixin), PEFT additions (DoRA + IA3 + Prefix + Prompt tuning on top of LoRA + Adapters), quantization (PTQ INT8 weight-only + QAT 8da4w via torchao), pruning (magnitude + 2:4 semi-structured), model surgery (Net2Net widen / deepen + drop_layer + low_rank_factorize + expand_embedding), embeddings (contrastive trainer + FAISS export), decoder-only LM (TransformerNN + NNTransformerParams + NNTokenizerParams + GenerativeNNModel.generate() with KV-cache), GGUF write + Ollama Modelfile bundle, model-internals visualization (torchinfo summary + weight histogram + activation map + Captum attribution + Netron export), I-JEPA self-supervised pretraining (+ small ViTNN encoder), Mixture-of-Experts (MoELinear + moe_train_step_factory with Switch-style aux loss), Born-Again Networks (iterated self-distillation), Feature-KD (FitNets-style), DPO (preference fine-tuning for LMs), LogitsProcessor chain (temperature / top-k / top-p / repetition-penalty), ONNX dynamo export opt-in, and assorted ergonomic improvements.
Every change preserves back-compatibility with existing run.id hashes and on-disk checkpoint formats — new params fields all follow the omit-when-default state() invariant.
Fixed — ONNX input coercion (PR #30)¶
NNModel.to_onnx(example_input=np.ndarray)— a single 2-Dnp.ndarraywas being unpacked row-by-row intoNrank-1 inputs becausenp.ndarrayis iterable; onlytorch.Tensorwas special-cased in the singleton-wrap branch.torch.onnx.exportthen raisedTypeError: forward() takes 2 positional arguments but N+1 were given. Fix extends the singleton check to(torch.Tensor, np.ndarray); the subsequent per-element coercion handles both consistently. Newtests/test_to_onnx_inputs.pyregresses the four shapes the docstring promises (Tensor singleton, ndarray singleton, tuple, mixed tuple).
Added — model-internals viz attribution + ONNX dynamo opt-in¶
nnx.viz.attribute(model, x, *, method, target, **method_kwargs)— Captum-backed input-attribution wrapper. Single string-keyed dispatch over the six most common methods (integrated_gradients,gradient_shap,deep_lift,saliency,input_x_gradient,occlusion) returning(attribution_tensor, plotly.Figure). The figure renders the attribution as a PlotlyHeatmap(3-/4-D image-shaped inputs are mean-pooled over channels first). Captum is lazy-imported at the call site so the rest ofnnx.vizkeeps working without it; the missing-dep path raises a clearImportError("nnx.viz.attribute requires captum: pip install captum"). Sensible per-method defaults (baselines=zerosfor GradientShap,sliding_window_shapesfor Occlusion) preserve the one-call ergonomics. Optional dep promoted into the existingvizextra:pip install thekaveh-nnx[viz]now pullscaptum>=0.7.0alongsidetorchinfo>=1.8.0. 10 new tests intests/test_viz_attribute.py(unknown-method ValueError, IG return-shape + figure-type, saliency works, missing-captum ImportError viasys.modulesstub, every supported-method key end-to-end via@pytest.mark.parametrize).NNModel.to_onnx(..., dynamo=True)opt-in. Newdynamo: bool = Falsekwarg onNNModel.to_onnx. When True, dispatches through PyTorch's newtorch.export-based ONNX exporter (default in torch>=2.9; supports >2 GB models via external data; generally faster). The default (False) preserves the existing legacy TorchScript path — no behavior change for existing callers. The dynamo path lazy-importsonnxscriptand raises a clearImportErrorpointing at the newthekaveh-nnx[onnx-dynamo]extra (pip install thekaveh-nnx[onnx-dynamo]) if missing, rather than letting torch surface a less actionable failure.
Added — quantization (PTQ INT8 weight-only via torchao)¶
nnx.quantizepackage — post-training quantization built on top oftorchao(the replacement for the deprecatedtorch.ao.quantization, which is removed in PyTorch 2.10).nnx.quantize_int8(model: NNModel) -> NNModel— one-call PTQ INT8 weight-only quantization. Deep-copiesmodel.net, appliestorchao.quantization.quantize_(net, Int8WeightOnlyConfig(version=2))to the copy, and returns a newNNModelwhosenet.Linearweights are stored in int8 per-channel (symmetric). Activations stay FP32 — only the weights are quantized, so accuracy loss is typically a fraction of a percentage point. No calibration data, no retraining — pure post-process. The originalNNModelis left untouched so callers can keep both around for an accuracy delta comparison.- Vision + GNN compatible — any module exposing
nn.Linearsubmodules is a valid target. - ONNX export still works on the quantized model (
NNModel.to_onnxroutes throughtorch.onnx.export's legacy tracing path; torchao's quantized tensor falls back to dequantized matmul during the trace, so the exported ONNX is FP32 with the quantized weights baked in). Regression test included. - State-dict round-trips through
NNCheckpoint.to_file(the existing pickle path); the on-disk file shrinks by roughly the same ratio as the pickled state-dict (≈30% on the example below, closer to ~25% at production-scale dims).
- Runnable demo:
examples/12_quantize_int8.py— trains a small classifier (FP32), prints the FP32 val accuracy + state-dict size, callsquantize_int8once, prints the INT8 val accuracy + size, and confirms the quantized model still ONNX-exports. On the toy task the size reduction lands at ~69% with zero measurable accuracy delta. - New optional dependency:
pip install thekaveh-nnx[quantize](pullstorchao>=0.17). - 15 new tests in
tests/test_quantize_ptq.pycovering: returns a freshNNModel, preserves output shape, doesn't mutate the source, replaces Linear weights with a torchao-quantized tensor, preserves attached attrs (params/net_params/device/loss_fn), output stays within 5% relative L2 of FP32, pickled state-dict shrinks vs FP32,NNCheckpoint.to_fileround-trip shrinks on disk, ONNX export round-trip, deep-copy isolation (mutating quantized doesn't leak back),predict()end-to-end on a deeper model, clearImportErrorwhen torchao is missing, state-dict keys unchanged, idempotency-via-deep-copy (calling twice on the same source produces identical outputs),.train()/.eval()toggle still works.
Also shipped in this sub-section: nnx.quantize.qat_train_step_factory + nnx.quantize.QATLifecycleCallback — Int8DynActInt4WeightQATQuantizer fake-quant during training, real-quant on convert via the QATLifecycleCallback (prepare → train → convert lifecycle pinned to epoch boundaries). Re-exported from nnx.*. Tests in tests/test_quantize_qat.py cover prepare/convert idempotency, end-to-end training, and ONNX export of the converted model.
Deferred from this sub-section: INT4 weight-only PTQ lands in a separate follow-up PR.
Added — PEFT++ (IA3)¶
IA3Linear(base)— Infused Adapter by Inhibiting and Amplifying Inner Activations (Liu et al., NeurIPS 2022). The smallest adapter in the PEFT family: a single learned per-output-dimscalingvector applied multiplicatively to a frozennn.Linear's output. Trainable parameter count per wrapped layer is exactlyout_features— roughly two orders of magnitude smaller than LoRA at the same effective adaptation budget.scalingis initialized to all-ones so the forward output at step 0 equalsbase(x)exactly.apply_ia3_to(module, *patterns)— fnmatch-glob in-place wrap mirroringapply_lora_to. Same two-phase traversal and idempotency contract (existing IA3 wrappers are not re-wrapped).save_ia3_weights(module, path)/load_ia3_weights(module, source)— persist ONLY thescalingparameters, symmetric to LoRA's save/load idiom. The resulting checkpoint is tiny (a single vector per wrapped layer). Sameweights_only=Truesafety guarantee; same empty-dict-is-zero-op contract; same dict-source convenience overload.- 19 new tests in
tests/test_peft_ia3.py: validation (non-Linear base rejection), base-freezing, zero-init invariant (output == base at step 0, with and without bias), forward shape, trainable parameter set is exactly{scaling}, scaling init is all-ones, in/out features pass-through, scaling actually scales the output by a known non-unit value;apply_ia3_toempty-pattern rejection + selective wrap + wildcard wrap + idempotency + forward-preserves-at-init; save/load round-trip + base-keys-excluded-from-checkpoint + dict-source loading + bad-source-type rejection + empty-dict no-op contract.
Added — PEFT++ (DoRA)¶
DoRALinear(base, *, r, alpha, dropout)— Weight-Decomposed Low-Rank Adaptation (Liu et al., NVIDIA, ICML 2024 Oral). Subclass ofLoRALinearthat adds a trainable per-output-rowmagnitudeparameter and recomposes the layer's weight asW = magnitude * V / ||V||_cwhereV = W_0 + (α/r) · BAis the LoRA-augmented direction.magnitudeis initialized from||W_0||_cso the forward output at step 0 equalsbase(x)exactly (combined with LoRA's zero-init B). Often outperforms LoRA at the same rank with onlyout_featuresextra parameters — negligible vs LoRA'sr · (in + out)baseline.apply_dora_to(module, *patterns, r, alpha, dropout)— fnmatch-glob in-place wrap mirroringapply_lora_to. Same idempotency contract (existing LoRA/DoRA wrappers are skipped via the parent-is-LoRALinear check, which covers DoRALinear by inheritance).- DoRA reuses
save_lora_weights/load_lora_weightsfor thelora_A/lora_Bmatrices unchanged (the inheritance hierarchy ensures the LoRA filter still matches). Themagnitudevector is captured by the standardstate_dict()round-trip — single vector of lengthout_featuresper wrapped layer. - 16 new tests in
tests/test_peft_dora.py: validation (non-Linear base, r/alpha/dropout ranges), base-freezing, zero-init invariant (output == base at step 0, with and without bias), forward shape, trainable parameter set is exactly{lora_A, lora_B, magnitude}, magnitude init matches||W_0||_c, in/out features pass-through, LoRALinear subclass relationship;apply_dora_toempty-pattern rejection + selective wrap + wildcard wrap + idempotency on re-application + forward-preserves-at-init;save_lora_weightsround-trip via DoRA wrappers. nnx.paradigms.feature_kd_train_step_factory(teacher, *, auxiliary_layers, alpha, beta, temperature)— FitNets-style intermediate-layer feature distillation. Extends the existingkd_train_step_factorywith an additional MSE term between named teacher / student intermediate-layer activations:L = α · KL_soft · T² + β · MSE(student_act, teacher_act) + (1 − α) · L_hard. Forward hooks register on theauxiliary_layerspairs (teacher_layer_name → student_layer_name, resolved viann.Module.get_submodule); activations are collected per forward and the MSE term is averaged across pairs sobeta's scale is invariant to the pair count. The teacher freeze + eval-mode guarantee carries over fromkd_train_step_factory. The v1 factory requires shape-matched paired layers — theFeatureRegressorprojector for mismatched widths is deferred. Routes throughfinalize_stepfor the standard NaN guard + grad-clip path. Re-exported fromnnx.paradigms.*andnnx.*.
Added — born-again self-distillation¶
nnx.paradigms.born_again_train(model, *, generations, train_params, **kd_kwargs) -> list[NNRun]— iterated self-distillation wrapper. Generation 0 trains plain (no teacher); each subsequent generation uses a deep-copied, frozen, eval-mode snapshot of the model after the prior generation as the teacher in a Hinton-style KD step (composed viakd_train_step_factory). Returns the per-generationNNRunlist so callers can inspect the convergence trajectory. Born-again networks (Furlanello et al., ICML 2018) often match or slightly outperform the original — the soft targets act as an implicit regularizer. 9 new tests covering generations-count validation, KD-factory not invoked on generation 0, KD-factory invoked on generations 1+, teacher snapshot is a deepcopy (not the live model), teacher requires_grad=False + eval-mode at handoff, kwargs forwarding, teacher isolation from subsequent training, top-level re-export, and end-to-end model mutation across generations.
Added — Mixture-of-Experts (tutorial-grade)¶
nnx.MoELinear(in_features, out_features, *, num_experts, top_k=2)— sparse top-k MoE drop-in fornn.Linear. Router (bias-lessnn.Linear) emits per-expert logits; thetop_kexperts per token are selected, their outputs are weighted by softmax-renormalized gating values, and the per-token result is the weighted sum. Exposes.last_aux_lossafter each forward — the Switch-Transformer load-balancing penaltyN · Σ_i f_i · P_iwheref_iis the dispatch fraction andP_iis the mean router probability for experti. The penalty is minimized at value 1 (NOT 0) when routing is perfectly uniform across experts. Validatesnum_experts ≥ 2,top_k ∈ [1, num_experts]at construction.nnx.paradigms.moe_train_step_factory(*, aux_loss_weight=0.01)— supervised training step that addsaux_loss_weight · Σ_layer layer.last_aux_lossto the main loss, summed across everyMoELinearin the net. Routes through the shared_step_helpers.finalize_stepfor the standard NaN-guard + grad-clip tail (same shape as the KD / SimCLR / Mixup / CutMix factories). Works on nets with zero MoE layers too — the aux sum just collapses to 0 and the step is exactly supervised.- Runnable demo:
examples/14_moe_classifier.py— a feed-forward classifier whose hidden layer is anMoELinear(4 experts, top-k=2). Prints router / expert / classifier param breakdown, trains withmoe_train_step_factory, and verifies the aux loss decreases across the run (routing balances out). - 22 new tests across
tests/test_nn_moe.py(12) andtests/test_paradigms_moe.py(10): MoELinear input validation + forward shape + router / experts module shape + top-k routing invariant +last_aux_losspopulated-after-forward + non-negativity + uniform-routing-equals-1 (minimum-value math) + above-minimum-when-skewed + load-balancing converges under SGD on the aux loss; paradigm factory validation + end-to-end aux-loss-decreases + finalize-step routing (NaN guard fires) + no-MoE-layers no-op + zero-weight collapse to supervised + multi-MoE-layer summation + AMP rejection + grad-clip honored + EDP return shape. - Scope explicitly limited to tutorial-grade. Production-scale MoE (MegaBlocks block-sparse kernels, expert parallelism across GPUs, token-dropping with capacity factor) is OUT — would be hollow wrapping over specialized libraries.
Added — pruning (nnx.prune)¶
nnx.prunepackage — two complementary network-pruning strategies layered on top of plainnn.Linearsubmodules, mirroring thennx.peftpackage shape (public functions, fnmatch glob patterns, in-place mutation).magnitude_prune(net, sparsity, *, layer_pattern="*", bake=True)— wrapstorch.nn.utils.prune.l1_unstructured. For eachnn.Linearwhose dotted name matcheslayer_pattern, zeros theround(sparsity · numel)smallest-magnitude entries of its weight matrix. Checkpoint-compat invariant:bake=True(default) callsprune.removeimmediately after each layer is pruned, so thestate_dictkeys stay identical to the pre-prune network — pruned checkpoints load into unpruned-network code understrict=True.bake=Falsekeeps the reparameterization in place (state_dict carriesweight_orig+weight_maskinstead ofweight); use this for iterative pruning schedules where successivemagnitude_prunecalls need to compose with the existing mask. Validatessparsity ∈ [0, 1). Returns the number of layers pruned (0 iflayer_patternmatches nothing).semi_structured_24(net, *, layer_pattern="*")— 2:4 semi-structured sparsity viatorchao.sparsity.sparsify_withsemi_sparse_weight(). Swaps each matchednn.Linear's weight with a 2:4 structured-sparse tensor subclass. Real wall-clock speedup on Ampere+ GPUs (~1.1× inference, ~1.3× training per torchao's ViT/SAM benchmarks); CPU and pre-Ampere hardware are unsupported by the underlying sparse kernel. The torchao dep is loaded lazily inside the function body so users on the magnitude-only path pay no dep cost; the dep is installed transitively via the existingquantize(torchao>=0.17) tooling.
- 17 new tests across
tests/test_prune_{magnitude,semi_structured}.py: zero-fraction correctness; state_dict-keys preservation under bake=True (THE checkpoint-compat invariant); pattern-filter selectivity; idempotency on already-zeroed weights; iterative bake=False path; sparsity bounds rejection + sparsity=0 no-op + no-match returns 0; smallest-magnitude-go-to-zero correctness; full state_dict round-trip into a fresh unpruned net; CUDA-gated swap-actually-happens (skipped on CPU); monkey-patched pattern-filter selectivity forsemi_structured_24(decouples nnx's filter logic from torchao's CUDA-only kernel); torchao-importorskip guard. - Structured pruning that REMOVES channels / heads (and so breaks
state_dictshape) is deferred — needs a per-architecture surgery API the existing checkpoint format doesn't yet support.
Added — HuggingFace interop (safetensors + Hub mixin)¶
- safetensors as opt-in checkpoint format.
NNCheckpoint.to_file(path, format="safetensors")writes a safe, mmap-friendly file readable by ComfyUI / vLLM / AutoGPTQ / HuggingFace tools.NNParams,NNModelParams, andNNIterationDataPointare JSON-serialized into the safetensors metadata dict (the spec only allowsstr -> strmetadata, so a JSON wrapper is the cleanest fit). Pickle remains the default format for back-compat;NNCheckpoint.from_file(path)auto-detects via magic-byte sniff (modern torch.save starts with the ZIP containerPK\x03\x04, legacy / bare pickle starts with\x80, safetensors starts with neither). Requirespip install thekaveh-nnx[hub]. NNModelis now HuggingFace-Hub-publishable viaPyTorchModelHubMixin. Freemodel.save_pretrained("./dir"),model.push_to_hub("user/repo"), andNNModel.from_pretrained("user/repo" | "./dir"). The on-disk layout is the canonical Hub flat layout:model.safetensors(weights),config.json({"net_params": <state>, "params": <state>}using the publicstate()form NNRun hashes), and an auto-generatedREADME.mdmodel card. Without thehubextra installed, all three methods raise a clearImportErrorpointing back atpip install thekaveh-nnx[hub].thekaveh-nnx[hub]extra — pulls insafetensors>=0.7.0andhuggingface_hub>=1.4.0. Both deps are runtime-import-guarded, sopip install thekaveh-nnxkeeps working without them.docs/hub.md— when-to-use guide for both tracks (safetensors checkpoints + Hub mixin), a local save/load walkthrough, the Hub publish/download path, and the explicit non-goals.
Added — embeddings (contrastive trainer + FAISS export)¶
nnx.embeddingspackage — the one RAG-adjacent surface NNx ships. Users train a domain-specific text embedder via the existing SimCLR / NT-Xent machinery, then export the trained model to a FAISS index for any retrieval framework (LangChain / LlamaIndex / Haystack / raw FAISS) to consume. NNx does NOT host the RAG stack — chunking, reranking, prompt orchestration, vector-DB clients are inference-time concerns and explicitly out of scope.ContrastiveTextDataset(pairs)— wraps(anchor, positive)string tuples as atorch.utils.data.Dataset. Validates input shape + types up-front (empty list / non-tuple / non-string entries all raiseValueError).train_contrastive(backbone, dataset, *, n_epochs, batch_size, lr, temperature, ...)— high-level training loop. Builds aDataLoaderwith the string-awarepair_collate, runs NT-Xent (nnx.nt_xent_loss) updates over the trainable parameters of the backbone (anythingrequires_grad=True; composes withnnx.freeze/nnx.apply_lora_to), returns the in-place-mutated backbone. Accepts either asentence_transformers.SentenceTransformeror any plainnn.Module(list[str]) -> Tensor[B, D].text_contrastive_train_step_factory(*, temperature)— lower-levelTrainStepFnfactory for users who want NNx's full callback / checkpoint /runs/<id>/machinery wrapped around the contrastive step (drive it throughNNModel.train(train_step_fn=...)with aDataLoaderthat yields(anchors: list[str], positives: list[str])batches).embed_texts(backbone, texts, *, batch_size, device, normalize)— inference-time encoder; runstorch.no_grad()+eval(). Used byexport_to_faissinternally and exposed for ad-hoc similarity probes.export_to_faiss(backbone, corpus, out_path, *, index_type, normalize, ...)— embed corpus → build a FAISS index of the requested type (IndexFlatIPfor cosine via normalize-then-IP,IndexFlatL2for L2 distance,IndexHNSWFlatfor approximate ANN withM=32) → write to disk viafaiss.write_index. Lazyfaissimport with a clear "install nnx[embeddings]" message on the failure path.export_to_safetensors(backbone, out_path)— persist backbone weights for HuggingFace Hub / sentence-transformers reload. Uses thesafetensorsformat when the package is importable (transitive viasentence-transformers≥3); falls back to plaintorch.saveotherwise.
embeddingsoptional extra inpyproject.toml— pinsfaiss-cpu>=1.7+sentence-transformers>=2.7. Both are optional at import time; the package imports cleanly without them and theImportErroris deferred to the call site that actually needs each one.- Runnable demo:
examples/13_train_domain_embedder.py— synthesizes 40(sentence, paraphrase)training pairs, trains a tiny bag-of-words hash embedder from scratch for 5 epochs (mean anchor-positive cosine: 0.61 → 0.98), exports to a FAISSIndexFlatIP, reloads from disk, and runs a top-3 query (the paraphrase comes back at #2 with cosine ≈ 0.99). Network-free, CPU-only, ~10s end-to-end. - New docs page:
docs/embeddings.md— when to use, install, quickstart, full API, composition withnnx.freeze/nnx.apply_lora_to, and the explicit "what this is NOT" list (no chunker, no reranker, no vector-DB client, no RAG-framework wrapper). - 28 new tests across
tests/test_embeddings_{contrastive,faiss_export}.py: dataset validation (empty / non-tuple / non-string entries),pair_collateshape, end-to-end "training reduces anchor-positive cosine distance" assertion on a synthetic 32-pair dataset (the headline TDD test), embed_texts batch-invariance + normalize on/off,text_contrastive_train_step_factorybad-batch rejection + weights-move-on-step, FAISSIndexFlat{IP,L2}+IndexHNSWFlatindex construction, "embed 100-text corpus → save → reload → top-1 self-similarity" assertion (the FAISS-export TDD test), explicit-normalize override semantics, safetensors-roundtrip via bothsafetensorsand thetorch.savefallback. FAISS / safetensors tests skip gracefully when the optional dep isn't installed. tests/conftest.pysetsKMP_DUPLICATE_LIB_OK=TRUE+OMP_NUM_THREADS=1at session start — sidesteps a macOS-specificfaiss-cpusegfault in its parallel search kernel whentorch'slibomp.dylibgot loaded first. Harmless on Linux CI.
Added — LM path: TransformerNN + tokenizer + generate¶
Nets.TRANSFORMERenum variant — decoder-only LM dispatched through the standardNNModelParams(net=Nets.TRANSFORMER, ...)factory path. Back-compat-safe: existing pre-TRANSFORMERrun.yamlfiles load unchanged.TransformerNN— decoder-only stack matching LLaMA / Mistral conventions: token embeddings + NTransformerBlocks (pre-norm with RMSNorm + RoPE + SwiGLU FFN + multi-head causal attention) + final RMSNorm + tied LM head. KV-cache seam wired but the low-level default isuse_cache=False;GenerativeNNModel.generateflips it on viaforward_with_cache(see "Added — generation: LogitsProcessor chain + KV-cache") without changing call sites.NNTransformerParams(NNParams)— frozen dataclass holdingvocab_size,n_layers,n_heads,d_model,ffn_mult,max_seq_len,rope_base,tie_embeddings,attn_dropout,resid_dropout. Lifts the GraphAttNNn_heads-on-NNParams pattern by subclassing. Every optional field omits itself fromstate()when at default — the broken-three-times omit-when-default invariant; covered by regression tests.NNTokenizerParams— wrapstokenizers.Tokenizer(HF Rust BPE).state()returns{"path": "<tokenizer.json>"}; the tokenizer payload lives on disk, only the pointer goes intorun.yaml. Companiontrain_bpe(...)helper trains a tiny BPE from either file paths or an in-memory text iterator. Available when thethekaveh-nnx[lm]extra is installed.GenerativeNNModel(NNModel).generate(prompt, max_new_tokens, temperature, top_k, top_p, repetition_penalty, stop, seed)— autoregressive decode via aLogitsProcessorchain (RepetitionPenalty→TopKFilter→TopPFilter→TemperatureScaling; canonical order documented in the post-#54 fix above).temperature=0short-circuits to deterministic greedy; same-seed sampling reproducibility is part of the contract.- New example
examples/11_tinystories_lm.py— end-to-end TinyStories-class training run: train a BPE on the corpus, build a small Transformer, train next-token prediction via a customtrain_step_fn, then sample. Ships with an inline fallback corpus so it runs offline;--use-hfdownloads TinyStories. - New docs page
docs/lm.md— when/how to use the LM path. Linked from README §1.2 + §5. pyproject.tomllmextra —tokenizers>=0.20,datasets>=2.20. Opt-in so the Rust tokenizer binary isn't pulled for non-LM users.
Migration notes¶
These two fixes shift run.id hashes on disk. Older runs/<id>/ directories on disk continue to load by their existing directory name; recomputed ids land in a fresh directory.
- Default-AMP runs now hash to a different
run.idthan they did between pass-2 and this audit. Themixed_precision=Falsedefault is now correctly omitted fromstate()(back-compat invariant from before pass-2). - Plateau-scheduler runs now hash to a different
run.idthan they did between the Schedulers-enum addition and this audit. Thekind=Nonedefault + its variant-specific knobs (step_size / T_max / max_lr / total_steps / warmup_steps) are now correctly omitted fromstate()when at their defaults (same back-compat invariant).
Fixed — back-compat invariant audit¶
NNModelParams.state()omitsmixed_precisionwhen False. The field was added in pass-2 but always emitted intostate(), breaking the omit-when-default back-compat invariant. Every default-AMP run had a shiftedrun.idversus pre-pass-2 runs with otherwise identical config. One-time hash shift: any existing default-AMPruns/<id>/directory will recompute to a different id after this fix — load by the on-disk directory name still works; recomputed ids will land in a fresh directory.NNSchedulerParams.state()omitskindand the variant-specific knobs (step_size/T_max/max_lr/total_steps/warmup_steps) when None. Same omit-when-default invariant: a plain ReduceLROnPlateauNNSchedulerParamsnow hashes to the samerun.idas it did before theSchedulersenum was added. Existing on-disk runs with explicit-None entries still load (the legacy form is tolerated infrom_state).- In-memory
best_checkpointtracking aligned with on-disk BEST.NNModel.train()'sbest_checkpointreassignment used a different comparison than the BEST write inside_save_checkpoints. Whenval_loader=None(so everyval_edpis None), the in-memory tracker effectively held LAST while the on-disk BEST tracked training error. Both now go through the same_best_errhelper. _best_errdeduplicated. Was triplicated — a local closure inNNModel._save_checkpoints, a module-level helper innn_run.py, and another module-level helper intrainer/trainer.py. Kept thenn_run.pyversion as canonical; the other two now import it.- Paradigm step factories honor
grad_clip_normand guard against non-finite loss. The four paradigmtrain_step_fnfactories (diffusion / SimCLR / Mixup / CutMix) plus KD now route through a sharednnx._step_helpers.finalize_stephelper. Previously they silently droppedNNOptimParams.grad_clip_norm, and diffusion / SimCLR / Mixup / CutMix had no NaN/Inf guard — only KD checked. New explicit rejection: the helper raisesValueErrorifNNModelParams.mixed_precision=True(paradigm steps don't handle the scaler) or ifaccumulate_grad_batches != 1(no cycle-aware accumulation). Previously these were silently ignored; users with those knobs set now see a clear error. ModelCheckpointcallback actually saves now. The body wasif ctx.epoch in self.epochs: pass— a no-op stub. Now writesruns/<run.id>/checkpoints/<tag>_e<epoch>.ptvia the atomic-write path on matched epochs.FeedFwdNN.from_fileusestorch.load(weights_only=True)for consistency withNNCheckpoint.load_optimizer_stateandload_pretrained. State-dicts are tensor-only; the strict loader works AND removes the arbitrary-code-execution risk on user-supplied paths.- Documentation and comment cleanups:
docs/index.mdlisted only pass-2 features (added the five new tracks);docs/concepts.mdarchitecture diagram missed the five new subpackages (extended with a Specializations branch);examples/06's_make_loadersdocstring claimed class-conditional Gaussians that the code didn't implement (rewrote);freezing.pydocstring incorrectly claimedfnmatch *matches segment-boundaries (it matches across dots);loading.pykey_mapdocstring said "substring replacement" but the code does prefix replacement; KD's loss formula inparadigms/distillation.pymodule docstring + inline comment ANDdocs/concepts.mdall reversed the KL direction — the math isKL(teacher || student)(standard Hinton), but the doc strings readKL(student || teacher);peft/adapters.pyactivation docstring saidnn.GELU()(instance) but the default isnn.GELU(class factory); README's enums-as-factories bullet was missingNoiseSchedulers. Internal phase labels (Track A / Track B / Track C / pass-2 R2 / R3 / R4) that had leaked into published code/docs/tests have been replaced with descriptions of WHAT the referenced thing is.
Added — model-internals viz (nnx.viz subpackage)¶
nnx.vizsubpackage — sibling of the existingnnx.vis_utils(which handles run-output viz: training curves, confusion matrices, t-SNE of checkpoint logits).nnx.vizcovers the model itself rather than what the run produced. Two primitives ship in this PR;activation_map,netron_export, and Captum attribution land in a later PR.nnx.viz.summary(model, *, input_size=..., depth=4, col_names=...)— Keras-style parameter table via a thintorchinfo.summarywrapper. Returns thetorchinfo.ModelStatisticsobject directly so callers can both print the formatted table AND query.total_params/.trainable_params/.total_mult_addsfor programmatic regression assertions. Accepts anNNModel(unwrapped to.net) or anynn.Module.nnx.viz.weight_histogram(model, *, bins=64, cols=3, fig_width=1000, row_height=200)— per-parameter Plotly histogram grid. Walksmodel.named_parameters()and emits oneHistogramtrace per tensor in a subplot grid, consistent withvis_utils's Plotly-returning idiom. Useful for spotting dead layers, NaN / Inf weights, or saturation patterns. RaisesValueErroron a parameter-less module (which would otherwise produce a silently-empty figure).
- New
vizoptional extra —pip install thekaveh-nnx[viz]pulls intorchinfo>=1.8.0.nnx.viz.summaryraises a clearImportErrorpointing at the extra iftorchinfois missing;weight_histogramonly depends onplotly(already a core dep), so it works out of the box.
Added — PEFT (LoRA + adapters)¶
nnx.peftpackage — two complementary patterns for parameter-efficient fine-tuning of pretrained networks.LoRALinear(base, *, r, alpha, dropout)— wraps annn.Linear, freezes the base's parameters (requires_grad=False) on construction, and adds two trainable matriceslora_A(r × in, Kaiming-uniform init) andlora_B(out × r, zero-initialized) whose product is added as a residual scaled byα/r. The zero-init on B means output at step 0 equalsbase(x)exactly — fine-tuning starts from the pretrained behavior and diverges only as B picks up gradient. Validatesr > 0,alpha > 0,0 ≤ dropout < 1at construction.apply_lora_to(module, *patterns, r, alpha, dropout)— walksmodule.named_modules()and replaces everynn.Linearwhose dotted name matches any fnmatch glob with aLoRALinearwrapper, in place. Returns the count wrapped. Idempotent: re-applying against patterns that already match LoRA-wrapped layers is a no-op (the inner.baseis excluded from the walk). Same glob conventions asnnx.finetune.freeze.save_lora_weights(module, path)— writes ONLY thelora_A/lora_Bparameters viatorch.saveof a filtered state-dict subset. A small percentage of the fullstate_dictsize (single-digit % at production scale; closer to ~40% on tiny demo nets where r/dim is large — seedocs/concepts.md§11 for the math).load_lora_weights(module, source)— loads LoRA params from a path (weights_only=Truefor safety) or directly from a dict, viaload_state_dict(strict=False)so the frozen base's missing keys don't raise. Returns the number of tensors loaded.AdapterLayer(dim, bottleneck, activation=nn.GELU)— bottleneck residual blocky = x + up(act(down(x))).up.weightandup.biasare zero-initialized so the layer is the residual identity at step 0. Composed by the user into a customnn.Module— NNx doesn't ship a "wrap every block" helper because adapter insertion points are architecture-specific.
- Runnable LoRA demo:
examples/07_lora_finetuning.py— pretrains a small classifier, wraps every Linear with LoRA, fine-tunes on a different distribution, explicitly verifies every base parameter is bit-exactly unchanged across the fine-tuning run, and compares the LoRA-only checkpoint size against a fullstate_dictsnapshot. - 23 new tests across
tests/test_peft_{lora,adapters}.py: LoRALinear validation + base-freezing + zero-init invariant (output == base at step 0) + only-LoRA-trainable invariant + in/out features pass-through;apply_lora_toempty-pattern rejection + selective wrap + wildcard wrap + idempotency on re-application + forward-preserves-at-init; save/load round-trip + base-keys-excluded-from-checkpoint + dict-source loading + bad-source-type rejection; end-to-end PEFT contract (every base param bit-exactly unchanged + every lora_B param has moved); AdapterLayer shape + identity-at-init + parameter-count scaling + gradient-flow + dim validation + custom activation.
Added — training paradigms (KD / SimCLR / Mixup / CutMix)¶
nnx.paradigmspackage — fourTrainStepFnfactories for non-vanilla supervised paradigms, all consumed via the existingNNModel.train(train_step_fn=...)hook. No new params dataclass, no NNModel changes; each is a self-contained closure.kd_train_step_factory(teacher, *, alpha, temperature)— Hinton-style knowledge distillation. Mixes a temperature-softened KL divergence against the teacher's logits (α · KL · T²) with the standard hard-label loss ((1-α) · L_hard). The factory freezes the teacher's parameters and sets its net to eval mode on call, so the teacher provably cannot drift across the student's training. The hard term goes through the student'sloss_fnso KD works with any classification loss.simclr_train_step_factory(*, temperature)— SimCLR contrastive training. The training loader must yield(view1, view2)paired-view tensors per source sample.model.netis forwarded once per view (BatchNorm sees one view at a time). Reports the NT-Xent loss in both.lossand.error.nt_xent_loss(z1, z2, *, temperature)— the SimCLR loss exposed as a standalone for users wanting to compose it into custom training loops.mixup_train_step_factory(*, alpha)— Mixup batch augmentation:x' = λx_a + (1-λ)x_bwithλ ~ Beta(α, α). Works for any input rank (tabular, sequence, image). Reports λ-weighted accuracy as theaccuracyfield;accuracy + error == 1.cutmix_train_step_factory(*, alpha)— CutMix batch augmentation for 4D(B, C, H, W)image batches. Copies a random rectangle fromx_bintox_a, then re-weights the loss by the actual cut area (which can be smaller than the nominal Beta draw when the box clips at an edge). Raises a clearValueErroron lower-rank input — CutMix's spatial cut isn't well-defined without H and W.
- Runnable distillation demo:
examples/10_knowledge_distillation.py— pretrains a wider teacher (hidden_dims=[64, 64]) then distills into a much smaller student (hidden_dims=[16], roughly 4% of the teacher's parameters). The example explicitly verifies teacher weights are unchanged across the student's training run, demonstrating the factory's freeze guarantee. Honest about scope: doesn't claim to beat a non-distilled baseline on toy tabular data. - 19 new tests across
tests/test_paradigms_{distillation,contrastive,augmentation}.py: factory validation (alpha / temperature ranges), teacher freezing guarantee + teacher-eval-mode assertion, end-to-end loss-decreases (KD α=0.5) + α-boundary cases (α=0.0 collapse to supervised, α=1.0 pure distillation), NT-Xent properties (shape mismatch, finite + scalar output, loss smaller for aligned pairs than random), SimCLR step bad-batch-shape error, Mixup self-consistency (accuracy + error == 1), CutMix non-image input rejection + 4D end-to-end.
Added — diffusion (DDPM)¶
nnx.diffusionpackage — DDPM-style diffusion training and sampling, layered entirely on top of the existingtrain_step_fnhook onNNModel.train()(no Trainer, no NNModel internals touched).NoiseSchedulers— enum-as-factory with two variants:LINEAR(T, beta_min, beta_max)(original DDPM linear betas) andCOSINE(T, s)(Improved-DDPM cosine schedule). Each enum value's__call__returns a precomputedNoiseSchedule.NoiseSchedule— frozen dataclass holding the derived tensors (betas,alphas,alphas_cumprod,sqrt_alphas_cumprod,sqrt_one_minus_alphas_cumprod,posterior_variance). All 1D of length T..to(device)returns a copy with every tensor migrated. Notstate()-serialized — recoverable from(kind, T, kind-specific knobs).DiffusionMLP(input_dim, hidden_dims, time_embed_dim)— small conditional MLP: sinusoidal time embed → projection → concat with flat x → MLP → noise prediction.forward(x, t) → ε_pred. Handles arbitrary-rank inputs by flattening + un-flattening. Intentionally minimal; image-space diffusion calls for a U-Net the user supplies, with the same schedule / step / sampler machinery.diffusion_train_step_factory(schedule) -> TrainStepFn— closes over the schedule and returns aTrainStepFnsuitable forNNModel.train(train_step_fn=...). Per batch: samplest ~ Uniform[0, T), samplesε ~ N(0, I), computesx_t, predicts noise, backprops MSE. Reports loss as both.lossand.erroron the EDP so BEST tracking + ReduceLROnPlateau work.sample(model, schedule, shape, device=, generator=)— reverse-diffusion sampler. Runs T backward steps undertorch.no_grad()andmodel.net.eval(). The optionalgenerator=enables reproducible sampling for notebooks.sinusoidal_time_embed(t, dim)— standalone helper for the standard sinusoidal positional embedding, exposed for users building their own t-conditioned nets.
NNModel.train()net-params fallback — the run-construction line now readsself.net_params(always set in__init__) instead ofself.net.params(FeedFwdNN-specific attribute). Back-compat-safe: the values are identical for the existing supervised path. Lets callers swapmodel.netfor a customnn.Modulepost-construction (the same idiom the multi-optimizer Trainer's GAN demo uses) without breakingNNModel.train().- Runnable diffusion demo:
examples/08_diffusion_2d_mixture.py— DDPM on a 2D mixture of 4 Gaussians at (±2, ±2). Verified end-to-end (loss 1.0078 → 0.6048; samples land in all four modes at roughly equal counts). - 27 new tests across
tests/test_diffusion_{schedules,nets,training,sampling}.pycovering schedule shape/monotonicity/clamping, net forward shape, full training + loss-decreases, sampling shape / finiteness / reproducibility / mode coverage.
Added — multi-optimizer Trainer (GAN / actor-critic)¶
nnx.trainerpackage —Trainerclass that parallelsNNModel.train()for scenarios where the per-batch update isn't a single supervised forward/backward/step. Built around the GAN G/D pattern, but applicable to actor-critic, EBM, contrastive multi-head, or any other multi-optimizer paradigm.Trainer(model: NNModel).train(params, trainer_step_fn, callbacks=)— builds onetorch.optim.Optimizerper entry inNNTrainerParams.optims, dispatches to a user-suppliedtrainer_step_fn(ctx) -> NNEvaluationDataPointper batch, writes the sameNNRun+ per-tagNNCheckpointartifacts asNNModel.train(). Nodefault_trainer_step— multi-optim updates are scenario-specific and silently running the wrong update is worse than requiring an explicit fn.NNTrainerParams— frozen dataclass withoptims: Mapping[str, NNOptimParams](name-keyed multi-optim config),schedulers: Mapping[str, NNSchedulerParams](one per optim, defaults to ReduceLROnPlateau when missing), plus the standardn_epochs/train_loader/val_loader/seed/save_phase_checkpoints/extra_metrics. Validates non-emptyoptimsand that every scheduler key matches an optim key.state()keys sorted for deterministicrun.id.TrainerStepContext— frozen bundle passed into atrainer_step_fn:model,batch,optimizers(dict),schedulers(dict),extra_metrics,batch_idx,epoch_idx. The companionTrainerStepFntype alias is exported.
- Strict
param_groupssemantics for multi-optim —build_param_groups(..., strict=True)(new keyword) drops parameters that match no spec instead of bucketing them into a default group. Threaded throughOptims.__call__(..., strict_param_groups=True). The Trainer passes True so disjoint optimizers don't co-own parameters via implicit default buckets. Defaultstrict=Falsepreserves the fine-tuning semantics introduced bynnx.finetune.param_groupsexactly. NNRun.trainer: Optional[NNTrainerParams]— populated by the Trainer; None forNNModel.train()runs. Strict back-compat: OMITTED fromstate()when None so existingNNModelrun.id hashes are unchanged.NNRun.load(id)round-trips trainer-mode runs by lazy-importingNNTrainerParams.from_statewhen the YAML carries atrainerblock.- Runnable GAN demo:
examples/09_gan_with_trainer.py— generator + discriminator packed into one nn.Module, two disjoint optimizers scoped viaNNParamGroupSpec(name_pattern="G.*" | "D.*"), alternating updates on a 1D mixture-of-Gaussians. Verified end-to-end on CPU.
Deferred from this PR: trainer-mode warm-resume. The Trainer writes only the model net's state_dict to its NNCheckpoints — there is no per-optimizer .opt.<name>.pt sidecar yet. NNTrainerParams does not carry resume_from_run_id / resume_from_checkpoint. Resuming a GAN's Adam state for both G and D will land as its own follow-up PR once the use case is exercised.
Added — fine-tuning infrastructure (freeze / unfreeze / param_groups)¶
nnx.finetunepackage with three submodules:freezing—freeze(module, *patterns)/unfreeze(module, *patterns)/frozen(module). Glob-pattern (fnmatch) toggling ofrequires_gradon submodule parameters; the standard transfer-learning idiom.NNModel.freeze/NNModel.unfreezeare convenience methods delegating to the free functions.loading—load_pretrained(module, source, *, key_map, strict, prefix)returns aLoadPretrainedResultwithloaded_keys/missing_keys/unexpected_keys. Sources: file paths (loaded withweights_only=Truefor safety), state-dicts, or othernn.Modules. Key remapping handles foreign naming conventions (torchvision / HuggingFace / etc.).param_groups—NNParamGroupSpec(frozen, kw_only, slots dataclass) for declarative per-layer LR / weight_decay overrides. The fine-tuning idiom of "small LR on the backbone, large LR on the head" expressed as a list of specs onNNOptimParams.param_groups.build_param_groups(module, specs, default_lr, default_weight_decay)is the helper theOptimsenum factory dispatches through.
NNOptimParams.param_groups: Optional[list[NNParamGroupSpec]]field. When set, the optimizer factory builds per-group dicts with the spec's lr / lr_multiplier / weight_decay overrides; frozen parameters are dropped. Strict back-compat:param_groups=None(default) is OMITTED fromstate(), so existingrun.idhashes are unchanged.NNModel.export_state_dict(path)— savesself.net.state_dict()to disk as a plain torch file (no NNCheckpoint wrapper). Companion toload_pretrainedfor the round-trip.
Added — train_step_fn hook on NNModel.train() (foundational)¶
train_step_fnhook onNNModel.train(). One optional kwarg that swaps out the supervised forward/backward/step for any user-supplied function. Unblocks non-supervised training paradigms (autoencoder, VAE, link prediction, recommendation, diffusion) without modifying NNx core. Default-None path is byte-identical to the prior loop. New public surface:TrainStepContext(frozen dataclass carrying model/batch/optimizer/scaler/grad_clip_norm/extra_metrics/accumulate_grad_batches/batch_idx/epoch_idx),default_train_step(ctx)(the standard supervised step, exported for users who want to layer behavior on top),TrainStepFn(type alias). Seven tests intests/test_train_step_hook.py; runnable autoencoder example atexamples/05_custom_train_step_autoencoder.py.- Public alias for
nnx.PredictResult(was reachable only viannx.nn.nn_model).
Changed — internal¶
NNModel.__fwd_pass→NNModel._fwd_pass. Required so the freedefault_train_stepcan reach it without Python name-mangling. Single underscore is still "weak private"; no external consumer touched the mangled_NNModel__fwd_passname.NNModel._train_stepbecomes a one-line wrapper arounddefault_train_stepfor back-compat with any hypothetical subclass that overrode it. Thetrain()loop itself no longer dispatches through_train_step.
Fixed¶
_save_checkpoints/_step_scheduler/_update_tqdm_postfixnow tolerate anNNEvaluationDataPointwitherror=None. Customtrain_step_fnhooks for non-supervised paradigms (VAE/autoencoder/diffusion) don't always have a classification error to report; the loop falls back throughval_edp.error → val_edp.loss → train_edp.error → train_edp.lossand skips the scheduler step entirely if nothing is set. Previously these three sites crashed withTypeErroronNone < float/float(None)/f"{None:.4f}".
Deferred¶
eval_step_fn/predict_fn— same pattern, butevaluate()andpredict()still assume supervised classification. First ml-lab task that needs custom eval (autoencoder, VAE, DDPM) will drive that.- Network registry (
Nets.register(...)) — each new architecture lands aNetsenum variant via its task's PR. - Loss registry — custom losses live inside
train_step_fntoday (the user computes the loss tensor manually). Lift to a registry when multiple tasks duplicate the same custom loss.
[Pass-2 unreleased] — comprehensive improvements pass 2¶
Second improvement pass on branch chore/comprehensive-improvements-pass-2, building on pass-1. Strict back-compat preserved throughout — every new field on a params dataclass defaults to its old value and omits itself from state() when the default holds, so existing run.id hashes are unchanged.
Added — features (warm-resume, gradient accumulation, custom epoch checkpointing, etc.)¶
- Warm-resume training.
NNTrainParams.resume_from_run_idandresume_from_checkpointload weights AND optimizer state from a prior run's checkpoint at the start oftrain(). Optimizer state is written as a.opt.ptsidecar so the existing pickledNNCheckpointformat is untouched. - Gradient accumulation via
NNOptimParams.accumulate_grad_batches(default 1). Loss is scaled by 1/N;zero_grad/optimizer.stepfire on cycle boundaries; AMP unscale + grad-clip both honor the cycle. - TensorBoardCallback and WandbCallback — stream per-epoch train/val metrics + LR. Lazy import so users not on the path don't pay the dep cost.
NNModel.to_onnx(path, example_input)— export the network via the legacytorch.onnx.exporttracing path (noonnxscriptneeded). Marks dim-0 dynamic by default.NNTabularDataset— wraps a pandas DataFrame into train/val/test loaders matching theNNDatasetBasecontract.- Custom metrics via
NNTrainParams.extra_metrics={name: fn}. Eachfn(Y, Y_hat) -> floatpopulates the newNNEvaluationDataPoint.extradict; survives theNNRun.save/NNRun.loadround-trip viaextra.<name>CSV columns.
Added — reproducibility (seeded RNGs + env snapshot in metadata.yaml)¶
nnx.set_seed(seed, strict=False)pins Pythonrandom, NumPy, torch CPU+CUDA, and cuDNN.strict=Truealso callstorch.use_deterministic_algorithms(True).nnx.dataloader_worker_init_fn— pass toDataLoader(worker_init_fn=...)for per-worker deterministic seeds.NNTrainParams.seedrunsset_seedattrain()entry; included instate()only when set.nnx.env_snapshot()captures library / torch / numpy / python / platform / CUDA / git-commit info. Written byNNRun.save()toruns/<id>/metadata.yaml— separate fromrun.yamlso it does NOT contribute torun.id.
Added — API ergonomics (predict tuple unpack, file= kwargs, NNCheckpoint helpers)¶
NNModel.predict(X)acceptsnumpy.ndarray,torch.Tensor, tuples thereof, or aDataLoader(labels in batches are discarded). Returns aPredictResultNamedTuple that unpacks positionally as(logits, classes)for back-compat.NNTrainParams.save_phase_checkpoints: bool = True. Set False to skip the FIRST + Q1/Q2/Q3 cycle (LAST + BEST still always saved) — useful for tiny experiments or huge models.Devices.torch_device()/Devices.get_torch_device()returntorch.devicedirectly without the.()dance.Utils.print_tree/print_tableacceptfile=for output redirection.nnx.__version__resolves fromimportlib.metadata; falls back to"0.1.0+local"when editable-installed.pyprojectkeywords expanded (training, checkpointing, callbacks, experiments, reproducibility, neural-networks, research).
Added — reliability (NaN-loss guard, gradient clipping, atomic NNRun.save)¶
- NaN/Inf guard in
NNModel._train_step— raisesFloatingPointErrorrather than letting divergence corrupt checkpoints silently. - Gradient clipping via
NNOptimParams.grad_clip_norm: Optional[float]. AMP-aware (unscales before clipping). - Incremental persistence —
NNRun.save()runs after every epoch, not just at the end.KeyboardInterrupt/ OOM mid-training now leaves a loadable partial run. - SECURITY note on
NNCheckpoint.from_filecalling out the arbitrary-code-execution risk ofweights_only=Falseon untrusted files. - Re-pin
loss_fntoself.deviceon everyevaluate()call (guards against late device reassignment).
Fixed — correctness (evaluate aggregation, NNOptimParams.is_valid, callback isolation)¶
NNOptimParams.is_valid()now returnsFalse(not implicitNone) for unknown enum variants — invalid configs no longer slip past thenot params.optim.is_valid()pre-flight check.NNModel.train()toleratesDataLoaders without__len__(IterableDataset-backed). Falls back to a tqdm bar with no total.NNRun.save()falls back to writingbest/POINTER.txtwhenos.symlinkraises (Windows without developer mode).NNModel.evaluate()aggregates Y / Y_hat across batches and computes metrics once on the aggregate, fixing unequal-final-batch weighting. RaisesValueErroron an empty loader instead of returning NaN.NNIterationDataPointgets a docstring spelling out thatval_edpis populated only on the LAST idp of each epoch — readers shouldn't expect it on every row.
Changed — tooling (pyproject extras, conftest hygiene, type-checker config)¶
- CI runs pytest under coverage (
pytest-cov), uploadscoverage.xmlartifact on Python 3.11. - CI runs pyright in basic mode (
continue-on-error: truetoday; will tighten to--strictover time). NNX_TQDM_DISABLE=1silences the training progress bar — autouse'd intests/conftest.pyso pytest output stays clean.tests/conftest.pyexposes shared fixtures (tiny_model,tiny_classification_loaders,tmp_runs_root, ...).mkdocs.yml+docs/skeleton (index, quickstart, concepts, api). New.github/workflows/docs.ymlbuilds with--stricton every push and deploys viamkdocs gh-deployonmain. Newthekaveh-nnx[docs]optional extra..pre-commit-config.yamlwith ruff + standard pre-commit-hooks.CONTRIBUTING.mdcovering setup, workflow, back-compat invariants, testing..github/ISSUE_TEMPLATE/{bug_report,feature_request}.md+pull_request_template.md..github/workflows/release.yml— tag-triggered build + PyPI publish via OIDC trusted publishing.
Added — docs (concepts.md / quickstart.md / api.md)¶
examples/folder with four runnable scripts:01_synthetic_classification.py,02_resume_training.py,03_custom_metrics.py,04_onnx_export.py. All verified end-to-end on CPU.
Internal (Utils back-compat shim, vis_utils module aliases)¶
Utils.print_tree/print_table/flatten_dictare now module-level functions innnx.utils. TheUtilsclass is a thin shim binding the same functions as staticmethods, so existingUtils.method(...)callers continue to work with no semantic change.VisUtilsplotting helpers get module-level aliases (from nnx.vis_utils import confusion_matrixworks).
Additional fixes (post-initial-pass)¶
runs/bestPOINTER.txt fallback wasn't read during BEST comparison; env_snapshot subprocessed git on every save;.gitignoremissedruns/,tb_logs/,*.onnx,coverage.xml,site/.- Critical:
NNOptimParams.state()unconditionally emittedgrad_clip_norm=None, changing every existingrun.idhash. Pluscallbacks.pytop-level IPython import (pulling IPython into everyimport nnx);NNRun.all()crashed on missingruns/and tried to load stray files. mkdocs build --stricthad 4 warnings (specs in docs but not nav; griffe couldn't parse a docstring; missing type annotation onto_onnx.example_input).- Critical:
NNEvaluationDataPoint.extradidn't actually round-trip throughidps.csv. json_normalize flattened the dict on save butNNIterationDataPoint.from_statenever reassembled thetrain_edp.extra.*columns. The pass-2 claim that "extra survives idps.csv" was false until this fix. pytest-covlisted in dev extras but not installed locally. CI handles viapip install -e ".[dev]"; surfaced via cov-run on a fresh venv.NNEvaluationDataPoint.mean_ofsilently dropped theextradict from inputs.NNCheckpoint.load_optimizer_statenow usesweights_only=True(the state dict is structured tensors + dicts; the strict loader works AND removes the ACE risk).- Six conftest fixtures (
tiny_model,tiny_classification_loaders, etc.) defined but unused — premature abstractions deleted; CONTRIBUTING.md updated to match. NNTabularDatasetnow validatesfeature_cols/target_colagainstdf.columnsup-front with a clear KeyError; new test forenv_snapshotcache (introduced in R1 but never explicitly tested).- stray leading blank line in
nn_graph_dataset.py. - Real recovery gap:
NNRun.save()'s three writes (run.yaml, metadata.yaml, idps.csv) were non-atomic. A Ctrl-C mid-write left half-written files. New_atomic_write_texthelper does tmp + fsync + os.replace. NNCheckpoint.to_filehad the same non-atomic gap (torch.save direct to destination). New_atomic_torch_savehelper applies the same tmp + rename pattern to both the main checkpoint and the.opt.ptsidecar.- Atomicity also applied to the Windows POINTER.txt fallback; helper reordered (defined before its caller); pyproject
filterwarningsfor the upstreamtorch_geometric.distributed/torch.jit.scriptDeprecationWarnings; fix the scheduler test's optimizer-before-scheduler step order so the runtime UserWarning doesn't fire. - README "Other models" was a non-functional snippet (imported classes without showing how to wire them through
NNModel). Replaced with concreteNNModelParams(net=Nets.GRAPH_*)examples + a pointer at theexamples/folder. Added README subsections for Reproducibility, Warm-resume, and Custom metrics so the pass-2 features are visible from the top-level doc. test_imports.pywas missing smoke imports fornnx.seeding,nnx.nn.callbacks,nnx.nn.net.graph_nn_base,nnx.nn.dataset.nn_tabular_dataset, andnnx.nn.enum.schedulers. The test predated pass-1 and never grew with the codebase. Closed the gap so the cheapest-possible refactor signal is exhaustive again.release.ymlskippedtwine checkbetweenpython -m buildand the PyPI upload step. A malformed README or invalid classifier would only surface when PyPI rejected the upload — by then the tag is burned. Added atwine check dist/*verification step; also addedcache: pipto the setup-python step for parity with the other workflows.- Final sweeps: ran the literal README quickstart end-to-end, manually exercised the four
predict()input forms (ndarray, tensor, tuple-of-each), verified all internal markdown links resolve, and confirmedmkdocs build --strictis silent. No additional actionable findings.
Deferred (with rationale)¶
- D3 (split
NNModel.train()into aTrainingLooprunner): the existing helpers (_train_step,_save_checkpoints,_step_scheduler,_build_scheduler, ...) already break the loop body into testable units. A full extraction would be churn without proportional value. - D7 (versioned state-dict checkpoint format with a versioned reader): too risky for this back-compat pass. The pickled
NNCheckpointcontinues to work; theweights_only=Falsesecurity note in the docstring guards against the supply-chain risk. - D8 (Storage protocol for cloud backends): broad I/O abstraction touching every save/load site. Better as its own focused PR.
- N5 (md5 of
str(state)→json.dumps(sort_keys=True)): would change every existingrun.id. Can't ship under strict back-compat. - O5 / O6 / O7 (NNTrainParams config-vs-runtime split, callbacks-as-params, NNModel
__init__param rename): API breaks. Deferred. - O4 (frozen
_CallbackContextview): would change the surface callbacks can mutate — defer to a callback API revision. - P1 / P2 (per-batch device sync, loss.item() sync): would sacrifice per-batch metric granularity (idp.train_edp). Deferred.
- E7 (move ipython/kaleido to optional extras): would break
pip install thekaveh-nnxfor users relying on the default extras. Deferred.
[Pass-1 unreleased] — comprehensive improvements pass 1¶
The pass-1 series landed on branch chore/comprehensive-improvements-pass-1. Strict back-compat preserved: no public API renames, no on-disk format breaks, deep imports still resolve.
Fixed — correctness¶
NNDatasetnow carves the validation slice out of the sourcetrain=Truesplit, keeping the sourcetrain=Falsesplit intact for final evaluation. Previously val was a slice of test, leaking the test pool. Reported val metrics will differ between pre/post versions.NNDatasetrandom_splitsizes are computed as(total - val, val)instead of two truncated halves. Fixes the crash on odd-length source train sets.NNRun.saveno longer crashes when comparing against a prior BEST run that has noval_edp(e.g., a no-validation experiment). A new_best_errhelper falls back to train error, then+inf.NNEvaluationDataPoint.ofnow defaultsaverage="macro"forf1/recall/precision. The prior"micro"hardcoding made all three numerically identical to accuracy for single-label multi-class tasks. Passaverage="micro"to opt back in.VisUtils.multi_line_plot: removed deadcs = px.colors.qualitative.Plotly[...]assignment that was immediately overwritten; replaced thels[:len(ys)]legend loop (which depended on a leaked inner-loop variable) withn_lines_per_series = len(yss[0]); raisesValueErroron emptyyss.Activations.SOFTMAXreturns a closure that suppliesdim=-1, avoiding the implicit-dim warning and ambiguity fromF.softmax.NNModel._train_step: detachtrain_lossbeforefloat()to avoidUserWarning: Converting a tensor with requires_grad=True to a scalar may lead to unexpected behavior.
Fixed — deprecations / future breakage¶
- Migrated
torch.cuda.amp.{autocast,GradScaler}totorch.amp.*with explicitdevice_type="cuda". Thetorch.cuda.ampmodule has been deprecated since torch 2.4. NNCheckpoint.from_filecallstorch.load(weights_only=False). Without it, torch ≥ 2.6 (whereweights_onlydefaults toTrue) raisesUnpicklingErroron any savedNNCheckpoint(checkpoints pickle the full Python object, not a bare state dict).NNGraphDatasetreads the underlyingDataviadataset[0]instead ofdataset._data. The private accessor was renamed/removed across PyG versions.- Removed the top-level
from IPython.display import clear_outputimport innn_model.py. The actual use is incallbacks._LegacyCallback; leaving the top-level import made every consumer ofnnx.nn.nn_modelpull in IPython.
Added — initial release scaffolding (top-level re-exports, persistence root, viz figures)¶
nnx/__init__.pyre-exports the curated public surface (NNModel, params, callbacks, enums, nets, datasets, utils) with an explicit__all__. Deep imports (from nnx.nn.net.feed_fwd_nn import FeedFwdNN) still work for existing code.NNRun.save / load / all / checkpointsandNNCheckpoint.save / loadaccept an optionalroot: Optional[str] = Nonekwarg. Default is unchanged (cwd-relative); callers wanting to redirect persistence can now pass one.NNEvaluationDataPoint.ofacceptsaverage: str = "macro".VisUtils.{multi_line_plot, scatter_plot, two_dim_tsne_checkpoint_logits, confusion_matrix}now return theplotly.graph_objects.Figurethey build. The.show()call is gated on a non-None renderer so headless test envs no longer crash.tests/test_params_round_trip.py— contract test assertingobj == from_state(state())for every params dataclass. Fails loudly when fields drift.tests/test_train_integration.py— end-to-endNNModel.train()coverage on a tiny in-memoryTensorDataset, plusNNRun.loadround-trip andNNModel.from_checkpointreconstruction.NNOptimParams.momentumdocstring explaining the SGD-vs-Adam dual meaning.NNDatasetdocstring documenting that val is carved from train.- This
CHANGELOG.md.
Changed — tooling¶
- Ruff lint now selects
E,F,W,B(bugbear),I(isort),UP(pyupgrade). Style-preserving ignores:E701(case style),B024(structural base class),UP007/UP045(keepOptionaloverX | None). 213 auto-fixes applied (mostly import ordering). - CI matrix adds Python 3.12.
- CI ruff step no longer has
continue-on-error: true— lint gates merges.
Internal¶
nn_dataset_base.py: trimmed 9 unused imports.nn_model.py: removed emptyclass NNModel():parens.nn_dataset.py: switched to a localresolved_batch_sizesso downstream loaders don't readself.batch_sizeswhile it still holds the default tuple.
[0.1.0] — 2026-05-18¶
Initial extraction from thekaveh/ml.