50 deterministic checks, no LLM in the loop. 6 of them block the agent from applying
broken edits, 37 lint live as you build, and 7 compare an edit with what it was edited
from. Every rule has a trigger condition you can verify against your code.
50total rules
6guardrail gates
37advisor rules
7edit checks
~3msfull pass on a 100-layer model
Layer 1
Guardrail gates (6)
These run on every action the agent proposes, before anything touches the canvas.
A blocking finding requires explicit user override. Source:
src/utils/agentGuardrails.ts.
Any destructive action (delete_component, delete_components_matching, rename_components_matching, clear_canvas, replace_model) where downstream impact > 4 layers, or any of them are shape-changing.
Surfaces
Number of downstream layers affected, how many would reshape, how many carry weights (rebuild required).
Override
User confirms in the impact-preview card before the action lands.
An add_connection closes a cycle in the existing DAG. DFS check on the union of current edges and proposed edges.
Why
NN forward graphs are acyclic. Recurrence lives inside the layer type (LSTM, GRU), not as a graph cycle. A connection that closes a cycle is almost always a mis-step.
Source
DAG requirement matches PyTorch nn.Module.forward semantics. torch.autograd docs note that gradient computation requires a DAG.
add_component without afterName, and no follow-up add_connection referencing the new component as either endpoint.
Why
A new layer that's not wired is dead code. The agent occasionally proposes these when it forgets to chain edits; the warning flips the decision back to the user.
Propagates tensor shapes through the sandboxed post-action graph and surfaces only the issues the action would introduce. Sub-checks: attention embedDim % numHeads, GQA numHeads % numKVHeads, elementwise merge parent equality, concat axis compatibility, explicit linear inFeatures vs upstream, computeOutputShape throw, and outputs with NaN / 0 / negative dims at the first layer to introduce them.
Text-layer review tools (Cursor, Copilot) can't catch embedDim=384, numHeads=5 until the GPU rejects the kernel. This gate fires sub-millisecond, pre-apply.
A single parameter value falls outside its conventional range on add_component or update_params: e.g. dropout above 1, stride of 0, numHeads of 0. Uses the same convention-based ranges the inspector shows inline.
Why
These are values the shape gate cannot see: the graph still propagates, the model still builds, and the run is quietly wrong. A dropout of 1.2 is not a shape error, it is a silently dead layer.
Source
src/utils/paramConstraints.ts::validateParamValue, shared with the inspector so CI and the canvas never disagree.
Layer 2
Architecture advisor rules (35)
These run live as you build, every model change, no agent involvement.
They surface as inline warnings on the canvas and in the Advisor panel.
Source: src/utils/architectureAdvisor.ts.
A non-output node has inbound connections but no outbound connections.
Why
Compute happens but the result is discarded. Gradients flow into a black hole.
R05BatchNorm / LayerNorm after activationwarnordering
Trigger
A normalization layer is connected directly downstream of an activation (relu, gelu, swish, etc.).
Why
Normalization is meant to stabilize the pre-activation distribution. Applying it after activation breaks the assumption and shifts already-nonlinear features back toward zero mean.
R07Softmax / Sigmoid directly before Outputinfoordering
Trigger
An explicit Softmax or Sigmoid layer is wired directly into Output.
Why
PyTorch's nn.CrossEntropyLoss applies LogSoftmax internally; an explicit Softmax before it double-applies and slows training. BCEWithLogitsLoss has the same issue with Sigmoid.
R18Conv feeds Linear with no flatten / poolblockstructure
Trigger
A convolution (conv1d/2d/3d or a depthwise/separable/transpose variant) connects directly into a linear layer.
Why
Conv outputs a multi-dimensional feature map; Linear expects a flat [batch, features] tensor. The forward pass raises a shape error, or silently mis-multiplies the spatial dims. Insert a Flatten or a Global Average Pool between them.
Source
Standard CNN classifier construction (e.g. LeNet / AlexNet head).
An activation node (relu/gelu/silu/sigmoid/tanh/softmax/…) connects directly into another activation node with no Linear/Conv/Norm between them.
Why
Two activations in a row add compute but no expressivity (same-type is a no-op duplicate). A common slip is ReLU → Softmax, which clips logits to ≥ 0 and distorts the output distribution.
Source
Composition of pointwise non-linearities adds no representational power without an intervening affine map (universal-approximation premise).
A linear layer connects directly into another linear layer with no activation between them.
Why
Two stacked linear maps collapse into one (W₂·W₁), so the extra layer costs parameters but adds no representational power, usually a forgotten ReLU. Deliberate low-rank / factorized projections (down-proj → up-proj) are the safe exception.
Source
Linear-map composition closure; the rank of the product cannot exceed the smaller intermediate dimension.
A dropout layer is the last node before the output node.
Why
In training this randomly zeroes the final logits fed to the loss; at eval it is a no-op, so train and eval behaviour diverge. Dropout belongs before the final projection (Linear/Conv), not after it.
A downsampling convolution (conv1d/2d/3d, depthwise, or separable) has stride > kernelSize.
Why
The kernel jumps past its own footprint each step, so a band of the input is never read, a silent loss of information. Non-overlapping patches use stride == kernel (e.g. ViT 16/16); stride > kernel is almost always a typo.
A flatten or linear layer connects directly into a (non-transpose) convolution.
Why
Mirror of R18: a Conv needs a [channels, …spatial] feature map, but Flatten / Linear emit a flat [batch, features] vector. The forward pass raises a shape error unless the spatial dims are rebuilt with a Reshape / Unflatten first.
A normalization layer connects directly into another normalization layer (e.g. LayerNorm → BatchNorm).
Why
Normalizing an already-normalized tensor is redundant; the second layer mostly re-centres/re-scales what the first produced and just burns its own learnable parameters.
R26Pooling feeds Linear with no flattenwarnstructure
Trigger
A spatial pooling layer (maxpool, avgpool, adaptive pool) connects directly into a linear layer. Global pools are exempt: they already collapse spatial dims.
Why
Sibling of R18: pooling still emits a [channels, …spatial] map, but Linear expects a flat [batch, features] tensor, so the forward pass raises a shape error. Insert a Flatten or a Global Average Pool first.
A flatten layer connects directly into an attention layer.
Why
Flatten collapses the sequence dimension into one long vector, leaving a length-1 sequence, so attention has nothing to relate. Keep the [sequence, dim] layout and flatten only after the attention stack.
A ConvTranspose2d has kernelSize not divisible by stride (with stride > 1).
Why
Uneven kernel overlap during upsampling deposits more weight on some output pixels than others, producing checkerboard artifacts. Make kernelSize a multiple of stride, or upsample with Upsample + Conv (resize-convolution).
R29GroupNorm channels not divisible by numGroupsblockstructure
Trigger
A GroupNorm layer's channel count is not an exact multiple of numGroups.
Why
GroupNorm splits channels into equal groups; a non-divisible count raises at construction. Parallel to the GQA / attention head-dim divisibility checks. Set numGroups to a divisor of the channel count.
A single Linear exceeds ~1B parameters (inFeatures × outFeatures).
Why
A dense layer that large (~4 GB float32) almost always means a feature map was flattened without pooling first. Add a Global Average Pool / more downsampling, or factorize the layer. Embedding and vocab-projection heads are the expected exception.
Source
Parameter-budget hygiene; a single matrix this size dominates model memory.
R31Full multi-head attention at LLM scaleinfoperformance
Trigger
A transformer stack (≥6 attention layers) at LLM width (embedDim ≥ 2048) uses full multi-head attention everywhere, with no grouped-query or latent attention present.
Why
At real model scale the KV cache, not the weights, dominates serving memory. Full per-head K/V caching is what production LLMs move away from: grouped-query attention (e.g. 8:1) cuts the cache ~8×, and multi-head latent attention (MLA) shrinks it ~10× or more, without changing the parameter count. Advice, not a bug: set numKVHeads below numHeads, or switch to mla.
Source
Serving-cost roofline; the same KV-cache math the app and the public serving calculator run.
A Linear/Conv layer feeds a sigmoid or tanh activation directly.
Why
PyTorch's default Kaiming (He) init is derived for ReLU-family activations. Feeding a saturating activation from a He-initialized layer starts training in the saturated tails and shrinks early gradients. Use Xavier init with the matching gain, or a ReLU-family activation.
Source
Glorot & Bengio 2010 (Xavier) vs He et al. 2015 (Kaiming) derivation assumptions.
R33Deep attention stack without depth-scaled initinfopattern
Trigger
≥ 8 attention layers stacked in one model.
Why
Residual-branch outputs add up with depth; unscaled init lets activation variance grow layer over layer. GPT-2/LLaMA-family models scale residual output projections by depth: N(0, 0.02 / √(2L)).
Source
GPT-2 (Radford et al. 2019) init convention; carried forward by LLaMA/Mistral.
R34LM head width disagrees with embedding vocabinfostructure
Trigger
The model has an embedding with a numeric vocabSize and attention layers, but the final Linear feeding the Output projects to a different width.
Why
A language model's head must project back to vocab size (and is usually weight-tied to the embedding); a mismatch means the model cannot emit token logits. A classifier head over N classes is the expected exception, which is why this is info, not error.
Source
Weight-tying convention (Press & Wolf 2017); standard LM head contract.
R35KV cache exceeds serving budget at reference contextwarnperformance
Trigger
Total fp16 K/V cache across all attention layers (GQA-aware: counts numKVHeads, skips MLA) exceeds 4 GB for a single 8,192-token sequence.
Why
That much cache for ONE sequence is gone before weights or activations load; on a 24 GB GPU it caps concurrency at a handful of requests. Raise the GQA ratio, switch to MLA, reduce depth/width, or accept a shorter serving context.
Source
Same per-token KV math as R31 and the serving calculator, applied as an absolute budget instead of a pattern.
R36Predictive branch with no stop-gradient on the targetwarnpattern
Trigger
Model contains a JEPA predictor but no emaTarget node and no branch carrying stopGradient.
Why
Joint-embedding predictive objectives have exactly one degenerate solution: emit a constant. The momentum target that carries no gradient is the only thing ruling it out, so a gradient-carrying target is not a slower design, it is one whose loss reaches zero without learning anything.
R37Matryoshka prefix wider than the embeddingblockstructure
Trigger
A matryoshkaHead lists a nestedDims entry greater than its own embedDim.
Why
The head's entire contract is that embedding[:d] is usable on its own for each listed d. A d larger than the width names a prefix that does not exist, so a caller who truncates to it silently gets the full width instead and indexes at a size nobody chose. There is no reading of the graph under which a prefix longer than the whole was meant.
These compare a change with what it was changed from: two graphs, two config.json files,
or an adapter's target_modules against the model it attaches to. Each one catches an edit
that loads and runs and is wrong, so nothing raises for an agent to react to. They run in
POST /api/v1/plan (send base, config + base_config, or a
spec with an adapter) and in the pull request comment. Source:
src/utils/editChecks.ts.
C01Vocabulary resized, a token-indexed tensor was notblockedit
Trigger
Compared with the base, the input embedding (or vocab_size in config.json) changed size, and another tensor with the old vocabulary as its row count kept it: a per-layer embedding (embed_tokens_per_layer, vocab_size_per_layer_input), an output head, or a second copy of vocab_size in a nested text_config.
Why
New token ids index past the end of every tensor that kept the old size. The resize and the save both succeed and nothing raises, so an agent that iterates on errors has nothing to iterate on.
The base ties the output head to the input embedding (tie_word_embeddings: true, or a head with tied) and the changed model sets it false, drops the key, or keeps the tie on a head whose shape no longer matches the embedding.
Why
An untied head is a second vocab × hidden matrix that drifts from the embedding from the first step. When the flag is dropped on save, the reload decides by class default and comes back untied with no error.
C03Attention config keys that did not move togetherwarnedit
Trigger
In config.json: head_dim equalled hidden_size / num_attention_heads in the base and stayed put while one side moved; or num_attention_heads changed and num_key_value_heads did not, which silently changes the query-group size (or turns multi-head attention into grouped-query attention). A change that breaks divisibility is reported under G05 / R16 instead.
Why
An explicit head_dim is honoured as written, so the projections build at a width nobody chose and the model runs. Divisibility failures crash; these do not.
C04Context length moved without RoPE scalingwarnedit
Trigger
In config.json: max_position_embeddings was raised and the RoPE scaling factor (rope_scaling or rope_parameters) did not change, the factor changed and the length did not, or both moved by different ratios.
Why
Positions past the trained range are extrapolated rather than interpolated, or the extension is encoded but never used because runtimes size the window from max_position_embeddings.
The change spec's adapter.targets (PEFT target_modules: a list, a regular expression, or all-linear) matches no Linear, Embedding or Conv module in the graph, matched the way PEFT matches: full name equal to the entry or ending in .entry.
Why
An adapter with nothing to attach to trains zero parameters. PEFT raises on the first adapter; a second adapter on the same model is accepted silently.
Seen in
peft#3533 (inject_adapter silently accepts a second adapter whose target_modules match nothing).
C06LoRA targets reach only part of the modelwarnadapter
Trigger
Some entries of target_modules match nothing while others match, or an attention projection is matched in some layers and missed in the rest.
Why
PEFT only errors when every entry misses. A typo or another architecture's module name in a list of five trains on the other four without a word.
A target_modules entry matches a module PEFT cannot inject into: a class that holds a Linear without being one, a whole attention block, a norm.
Why
PEFT supports nn.Linear, nn.Embedding, nn.Conv1d/2d/3d and transformers Conv1D. A wrapper is refused or skipped; target the Linear inside it by its own name.
Seen in
peft#3129 (Gemma4ClippableLinear inherits from nn.Module, not nn.Linear).
What opt-in corpus reporting sends
The neurarch-lint GitHub Action (report-corpus: true) and the neurarch-mcp server (NEURARCH_REPORT=1) can share one anonymous structure+verdict row per linted model. Both are off by default.
Sent: the structural fingerprint (an 8-character hash of the layer-type histogram and edge count), the histogram and edge count themselves (which let the server verify the fingerprint is real), and the finding pairs, rule id plus severity.
Never sent: the model graph, parameter values, layer names, code, file paths, the repository identity, or any user or machine identifier. The rows are unauthenticated by design; there is no way to attribute one to a person. The server rejects any row whose fingerprint cannot be recomputed from the histogram it came with.
Reliability: reporting has a 5-second timeout and swallows every error. It can never fail a build or a tool call.
Try it on your own model
Paste a HuggingFace ID or drop a .py file. The same 40 rules run client-side, and now also run in CI on raw .py via the neurarch-lint GitHub Action, sub-millisecond.