20. 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]¶
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). Per
feedback_examples_optional_extras_docstringconvention, 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 (per [[builder-pattern-shape]] §11b); the missing-n_epochscase fell through to the dataclass ctor, which is the one error path inconsistent with the Builder rubric's "catch errors at the Builder boundary with a fix-naming message." 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 [[pypi-keywords-with-features]] 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-default contract from [[omit-when-default-state-invariant]].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 [[examples-seed-helper-override]] 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 [[examples-seed-helper-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 §11b ValueError contract 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 [[user feedback]] 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 the [[megamerge-pattern]] round-3 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. §11bBuilder-boundary pre-check pattern applied uniformly toNNOptimParamsBuilder/NNSchedulerParamsBuilder/NNTransformerParamsBuilder(post-PR-#52 overnight-maintenance pass). PR #52 established the [[builder-pattern-shape]] §11b convention 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 [[omit-when-default-state-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.