Relax strictness that costs more than it protects - #82
Merged
Merged
Conversation
The AST allowlist is unchanged and source is still never compiled or executed. hndl/_parser.py replaces the python -I -S worker, its JSON wire protocol, response validation and resource limits. Before ast.parse, a token-stream screen bounds bracket nesting (50) and Python operators or keywords (32; valid configs use none): on CPython 3.11-3.13 a few thousand chained operators otherwise crash ast.parse with SIGSEGV on a small thread stack. Parser MemoryError/RecursionError become E_RESOURCE. Statement handling is one dispatch table on each side: Validator.STATEMENTS in _parser.py and _Interpreter.STATEMENTS in config.py. The concat timing test now counts solver shape updates instead of racing a 5 s subprocess timeout. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Revalidation lists every differing port shape and argument per node instead of a one-line E_INTEGRITY. A plan missing only an operator default or an argument bound to a verified port dimension loads completed, with a warning naming the filled values and the new semantic digest. Missing values only a policy or relation search would choose are still refused. Digest mismatches say the file is corrupted or was hand-edited. Raise the default max_state_bytes from 1 GiB to 64 GiB (a 405M-parameter network failed to build by default), drop from_json's 16 MiB cap and its duplicate max_nodes check, and have capture read max_nodes from the shared limits. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Contributor
Author
|
CI note: the first two runs of 🤖 Generated with Claude Code |
This was referenced Sep 26, 2026
Merged
martyn
added a commit
that referenced
this pull request
Sep 26, 2026
* Add equalized learning rate to linear and transformer projections * Add HNDL primitives for style transformer coordinate rendering * Bump README status to 0.6.0 and drop host-application references The README status line was missed in the 0.6.0 release; a test now ties it to hndl.__version__. HNDL is a base library, so release notes, SPEC, and test docstrings no longer name a particular downstream consumer. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Keep 0.6.0 saved plans loading when operators gain arguments (#81) Adding equalized= to linear, attention and feed_forward without a version bump made every 0.6.0 plan holding those operators fail to load with E_INTEGRITY and changed their semantic digests. Arg gains since="<release>" for arguments added to a released operator. A resolved node that holds such an argument's default omits it from its args and argument origins; construct() fills it back in. Plans that do not use the new argument keep their 0.6.0 bytes and digests, and 0.6.0 plans load. equalized is declared since="0.7.0". Adds plan fixtures generated by the 0.6.0 release and a regression test that loads, builds and re-resolves them, and documents the rule in docs/ADDING_OPERATORS.md. Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Relax strictness that costs more than it protects (#82) * Parse configs in process instead of in a subprocess worker The AST allowlist is unchanged and source is still never compiled or executed. hndl/_parser.py replaces the python -I -S worker, its JSON wire protocol, response validation and resource limits. Before ast.parse, a token-stream screen bounds bracket nesting (50) and Python operators or keywords (32; valid configs use none): on CPython 3.11-3.13 a few thousand chained operators otherwise crash ast.parse with SIGSEGV on a small thread stack. Parser MemoryError/RecursionError become E_RESOURCE. Statement handling is one dispatch table on each side: Validator.STATEMENTS in _parser.py and _Interpreter.STATEMENTS in config.py. The concat timing test now counts solver shape updates instead of racing a 5 s subprocess timeout. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Explain plan mismatches and fill values a saved plan determines Revalidation lists every differing port shape and argument per node instead of a one-line E_INTEGRITY. A plan missing only an operator default or an argument bound to a verified port dimension loads completed, with a warning naming the filled values and the new semantic digest. Missing values only a policy or relation search would choose are still refused. Digest mismatches say the file is corrupted or was hand-edited. Raise the default max_state_bytes from 1 GiB to 64 GiB (a 405M-parameter network failed to build by default), drop from_json's 16 MiB cap and its duplicate max_nodes check, and have capture read max_nodes from the shared limits. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Document in-process parsing, plan completion and limit defaults Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Make the attention deepcopy test perturb bias entries independently Adding a constant to the whole relative-position bias table shifts every logit equally, which softmax cancels, so the assertion passed or failed on rounding noise (flaky on CI 3.14). Independent normal perturbations change the output for every input tried (0 of 500 indistinguishable). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Validate each input signature once instead of every port on every call (#83) * Validate each input signature once instead of every port on every call GraphModule._execute() checked the shape, dtype and device of every port of every node, and replayed the registered-state walk, on every forward call: a fixed ~10 us per node that made a five-node MLP at batch 32 run ~1.7x hand-written PyTorch. The first call for a given input signature (every external input's shape, dtype and device, plus training mode and autocast state) now runs the full checked program and the state check, as before. The signature is then remembered, and later calls with it compare the signature and run the modules back to back from a slot-indexed program. What was validated is invalidated on the events that can break it: - .to()/.cuda()/.double()/... (_apply) drops every signature and forces a full state re-check; - registering a parameter, buffer or submodule on any module in the graph (register_* or attribute assignment) advances a watch generation through torch's global registration hooks, filtered to modules of built graphs, so the next call re-checks state and revalidates every port; a registration during a fast call is checked at the end of that call, as before. Errors are readable: port mismatches name the node, operation and source line and show the contract in HNDL notation ("[B=32, 64]:float32 on cpu") beside the tensor that arrived; torch errors raised inside a node are wrapped as E_RUNTIME with the node's inputs and any state change that explains them; OOM errors pass through unwrapped. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Document validation once per input signature CHANGELOG, README, IMPLEMENTATION and SPEC describe when contracts are checked, what invalidates a validated signature, the new error wording, and the registered-state edits PyTorch runs no hook for. The parity benchmark's docstring now describes the cheap path it actually times. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Keep exceptions raised inside a node as their own type, with a note Wrapping a torch error raised inside a node in HNDLError (a ValueError) broke callers that catch RuntimeError around a forward call. The original exception now propagates unchanged --- type, message and traceback --- and gains one add_note() naming the node, its operation and source line, its inputs against their contract, any registered-state change that explains the failure, the upstream port that drifted after validation, and a hint when the error came from torch.compile. An exception passing out through nested graphs keeps only the innermost node's note. HNDLError (E_RUNTIME) is kept for what HNDL's own checks find: first-call validation and state mismatches. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Add bounded for loops to the config grammar (#84) `for _ in range(N):` with a positive int literal N repeats its body, which may hold any top-level statement including nested loops. The interpreter unrolls it before resolution, so a loop yields the same node IDs, plan, semantic digest and state_dict keys as the statements written out by hand. Explicit names gain the per-level iteration suffix (block3, res1_3), node source metadata records the iterations, and configuration, resolution and runtime errors report them. The multiplied-out node count is checked against max_nodes before the first node is emitted, loops nest at most 8 levels, and every other loop, conditional and comprehension form is rejected by name. The ViT and GPT example networks now use loops for their blocks. Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Release 0.7.0 Bump version, README status, and rename the Unreleased changelog heading for bounded config loops, once-per-signature contract checks, in-process config parsing, forward-compatible saved plans, and the equalized and coordinate rendering primitives. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
martyn
added a commit
that referenced
this pull request
Sep 26, 2026
* Add equalized learning rate to linear and transformer projections * Add HNDL primitives for style transformer coordinate rendering * Bump README status to 0.6.0 and drop host-application references The README status line was missed in the 0.6.0 release; a test now ties it to hndl.__version__. HNDL is a base library, so release notes, SPEC, and test docstrings no longer name a particular downstream consumer. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Keep 0.6.0 saved plans loading when operators gain arguments (#81) Adding equalized= to linear, attention and feed_forward without a version bump made every 0.6.0 plan holding those operators fail to load with E_INTEGRITY and changed their semantic digests. Arg gains since="<release>" for arguments added to a released operator. A resolved node that holds such an argument's default omits it from its args and argument origins; construct() fills it back in. Plans that do not use the new argument keep their 0.6.0 bytes and digests, and 0.6.0 plans load. equalized is declared since="0.7.0". Adds plan fixtures generated by the 0.6.0 release and a regression test that loads, builds and re-resolves them, and documents the rule in docs/ADDING_OPERATORS.md. Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Relax strictness that costs more than it protects (#82) * Parse configs in process instead of in a subprocess worker The AST allowlist is unchanged and source is still never compiled or executed. hndl/_parser.py replaces the python -I -S worker, its JSON wire protocol, response validation and resource limits. Before ast.parse, a token-stream screen bounds bracket nesting (50) and Python operators or keywords (32; valid configs use none): on CPython 3.11-3.13 a few thousand chained operators otherwise crash ast.parse with SIGSEGV on a small thread stack. Parser MemoryError/RecursionError become E_RESOURCE. Statement handling is one dispatch table on each side: Validator.STATEMENTS in _parser.py and _Interpreter.STATEMENTS in config.py. The concat timing test now counts solver shape updates instead of racing a 5 s subprocess timeout. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Explain plan mismatches and fill values a saved plan determines Revalidation lists every differing port shape and argument per node instead of a one-line E_INTEGRITY. A plan missing only an operator default or an argument bound to a verified port dimension loads completed, with a warning naming the filled values and the new semantic digest. Missing values only a policy or relation search would choose are still refused. Digest mismatches say the file is corrupted or was hand-edited. Raise the default max_state_bytes from 1 GiB to 64 GiB (a 405M-parameter network failed to build by default), drop from_json's 16 MiB cap and its duplicate max_nodes check, and have capture read max_nodes from the shared limits. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Document in-process parsing, plan completion and limit defaults Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Make the attention deepcopy test perturb bias entries independently Adding a constant to the whole relative-position bias table shifts every logit equally, which softmax cancels, so the assertion passed or failed on rounding noise (flaky on CI 3.14). Independent normal perturbations change the output for every input tried (0 of 500 indistinguishable). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Validate each input signature once instead of every port on every call (#83) * Validate each input signature once instead of every port on every call GraphModule._execute() checked the shape, dtype and device of every port of every node, and replayed the registered-state walk, on every forward call: a fixed ~10 us per node that made a five-node MLP at batch 32 run ~1.7x hand-written PyTorch. The first call for a given input signature (every external input's shape, dtype and device, plus training mode and autocast state) now runs the full checked program and the state check, as before. The signature is then remembered, and later calls with it compare the signature and run the modules back to back from a slot-indexed program. What was validated is invalidated on the events that can break it: - .to()/.cuda()/.double()/... (_apply) drops every signature and forces a full state re-check; - registering a parameter, buffer or submodule on any module in the graph (register_* or attribute assignment) advances a watch generation through torch's global registration hooks, filtered to modules of built graphs, so the next call re-checks state and revalidates every port; a registration during a fast call is checked at the end of that call, as before. Errors are readable: port mismatches name the node, operation and source line and show the contract in HNDL notation ("[B=32, 64]:float32 on cpu") beside the tensor that arrived; torch errors raised inside a node are wrapped as E_RUNTIME with the node's inputs and any state change that explains them; OOM errors pass through unwrapped. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Document validation once per input signature CHANGELOG, README, IMPLEMENTATION and SPEC describe when contracts are checked, what invalidates a validated signature, the new error wording, and the registered-state edits PyTorch runs no hook for. The parity benchmark's docstring now describes the cheap path it actually times. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Keep exceptions raised inside a node as their own type, with a note Wrapping a torch error raised inside a node in HNDLError (a ValueError) broke callers that catch RuntimeError around a forward call. The original exception now propagates unchanged --- type, message and traceback --- and gains one add_note() naming the node, its operation and source line, its inputs against their contract, any registered-state change that explains the failure, the upstream port that drifted after validation, and a hint when the error came from torch.compile. An exception passing out through nested graphs keeps only the innermost node's note. HNDLError (E_RUNTIME) is kept for what HNDL's own checks find: first-call validation and state mismatches. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Add bounded for loops to the config grammar (#84) `for _ in range(N):` with a positive int literal N repeats its body, which may hold any top-level statement including nested loops. The interpreter unrolls it before resolution, so a loop yields the same node IDs, plan, semantic digest and state_dict keys as the statements written out by hand. Explicit names gain the per-level iteration suffix (block3, res1_3), node source metadata records the iterations, and configuration, resolution and runtime errors report them. The multiplied-out node count is checked against max_nodes before the first node is emitted, loops nest at most 8 levels, and every other loop, conditional and comprehension form is rejected by name. The ViT and GPT example networks now use loops for their blocks. Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Close extension API gaps for operators defined outside HNDL (#86) * Close extension API gaps for operators defined outside HNDL - The global `ops` namespace resolves aliases against the registry of the active capture, so custom operators work as `ops.my_op()` under resolve_callable/network_from_callable(registry=...). A registry.ops factory whose exact declaration the capture's registry lacks now fails with E_CAPTURE and a message naming the registry to pass. - `hndl.relations` publishes the convolution arithmetic, spatial, elementwise_join and broadcast relations, plus conv_input_range, conv_transpose_input, conv_axis and conv_transpose_axis. Built-ins import from it; operators._relations re-exports it. - `hndl.testing` publishes the operator harness (check_operator and the per-check functions, example_params/operator_params for pytest). The built-in test_all_operators runs through it. pytest is imported lazily. - Tests in tests/test_extension_api.py exercise all three as an external package would. SPEC, README, ADDING_OPERATORS and CHANGELOG updated. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Test the harness's failure paths and address review nits - Exercise every check_build_and_run, check_declaration, check_round_trip and check_reference failure with a deliberately broken operator (or, for checks that guard HNDL itself, a patched resolver/repr/replay), asserting each message, so a check that stops checking fails the suite. - check_reference no longer crashes when a reference drops the gradient the module carries; it reports it. - check_declaration requires the class's own docstring instead of accepting one inherited from nn.Module. - check_operator accepts devices="cpu" / dtypes="float32". - A declaration missing from the registry fails with AssertionError, like one registered with a different declaration. - The E_CAPTURE hint only suggests ops.<alias> when that alias binds the same identity in the capture's registry. - conv_axis / conv_transpose_axis fail with E_CONSTRAINT for a port whose rank lacks the axis instead of leaking IndexError. - The README's my_silu declares an Example so check_operator runs on it, with a test that executes the README block through the harness. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Detect cleared parameters on the fast path and tighten the parity tolerance (#87) * Detect registered-state edits PyTorch runs no hook for on the fast path `del module.weight`, `linear.bias = None` and direct writes to a module's `_parameters`, `_buffers` or `_modules` fire none of torch's global registration hooks, so after the first validated call the unchecked path kept running (a layer silently without its bias) until a new signature, a move or cast, or a failing layer forced a full check. Each graph now records every module's three stores together with a copy of each whenever its state is found to match the build, and every call compares the two tuples in one C-level comparison: values are compared by identity first, so an unchanged store costs about 10-17 ns and never touches its tensors. A mismatch takes the validating path, which reports removed or added state with the usual E_RUNTIME naming the node and the change, and re-checks every port when a tensor was swapped in under the same name. .to()/casts drop the recorded copies so replaced tensors are released immediately. torch.compile tracing is unchanged: the compiling path returns before the comparison. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Tighten the parity benchmark tolerance from 2.0x to 1.5x Sixteen runs of the opt-in benchmark suite with the registered-state comparison in place measured mlp 1.00-1.13x, conv_stack 0.97-1.17x and transformer_block 1.10-1.24x of hand-written PyTorch. 1.5x leaves about 20% over the worst of those. The docstring records the runs, the machine, and the one noisy conv_stack reading (1.91x, on the code before this branch) that a loaded machine can produce. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Compare registered state without holding or comparing tensors The per-call store check compared copies of every _parameters/_buffers dict with ==, so a tensor swapped in under its name (functional_call) ran an elementwise Tensor.__eq__ and a full re-check on every call, kept the swapped tensors and their autograd graphs alive, and made copy.deepcopy fail on non-leaf, grad-tracking or vmap-batched tensors. Submodule dicts and empty tensor dicts are still compared by identity; non-empty tensor dicts are compared by length and which values are None, so removals, additions and None assignments are caught and swaps are not. __deepcopy__ skips the record, _validate records it once, and the docs list what is not detected. The parity benchmark now takes the fastest of ten interleaved rounds per side and keeps 2.0x for the arithmetic-bound convolution case. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Release 0.8.0 Bump version, README status, and rename the Unreleased changelog heading for the extension API (registry-aware ops, public hndl.relations, hndl.testing) and fast-path detection of hookless state edits. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Relaxes the strictness that costs more than it protects. The safety property is unchanged: config source is never compiled or executed. It goes through an AST allowlist, calls can only name registered operators with literal or tensor arguments, and there are no imports or attribute access. Every node of the AST is still validated before any registry lookup.
1. Config parsing moves in process
What it was. Every
resolve()/resolve_file()startedpython -I -S _parser_worker.pyunder rlimits (256 MiB address space, 2 s CPU, a 5 s wall-clock timeout) and exchanged a bounded JSON protocol. On top of that it had eight parser limits (source bytes, lines, AST nodes, depth, literal items and bytes, integer bits, initializer kwargs), protocol limits, and a second validator that re-checked the worker's JSON response. It only ran on Linux.What it protected. Two things. The worker kept the AST allowlist separate from any code execution, and it protected the host process from pathological source that exhausts the parser.
Why it's safe to relax.
ast.parseproduces data and executes nothing, so it doesn't need a separate process to keep the allowlist separate from execution. The allowlist itself is unchanged (src/hndl/_parser.py, moved from the worker). The resource risk is real, though, and I measured it rather than assuming it away:SyntaxError,MemoryErrororRecursionErrorwithin about 130 ms and 55 MiB. 3.14 also builds trees 32,000 levels deep forrelu()()()...and1+1+....-----1,not not ...,lambda: lambda: ..., ternary chains, and on 3.12/3.13 also on call, attribute, subscript and binary-operator chains. 3.14 does not crash. An exception handler can't catch this, so a plain in-processast.parsewould have been a regression.So before
ast.parsesees the source,_screenwalks the token stream. The tokenizer is iterative, so this walk can't overflow the stack. It fails more than 50 levels of bracket nesting withE_RESOURCE, and more than 32 Python operators or keywords withE_SYNTAX. Valid configs contain none of those operators or keywords, and signs on numeric literals and calls on names aren't counted. Every other level of AST nesting costs one of those tokens. An iterative AST depth walk (150 levels) remains as a backstop for the recursive validator. With the screen, 29 adversarial sources at the size cap (plus 3 large valid configs) parse or fail withHNDLErrorin a 256 KiB thread on 3.11, 3.12, 3.13 and 3.14. That set includes chains of every nesting construct, 49-deep brackets combined with 32 operators, decorators, match patterns, and indentation.test_pathological_nesting_fails_cleanly_on_a_small_thread_stackruns a set of these inputs in a 256 KiB thread in a child process. I checked that the test fails, with SIGSEGV, on 3.11–3.13 when the screen is disabled.These limits are dropped because the source cap and the operators' argument schemas already bound the values:
MAX_LINES,MAX_AST_NODES,MAX_LITERAL_ITEMS,MAX_LITERAL_BYTES,MAX_INTEGER_BITS(an oversized int still fails withE_RESOURCEfrom the argument bound),MAX_INITIALIZER_KWARGS, the protocol limits and response validation, the Linux requirement and the explicit version gate (requires-pythonalready has one). The initializer-scheme allowlist now comes fromhndl.settings.SCHEME_NAMES. The worker kept its own copy because it couldn't import the package.Measured (min of 20 runs, Python 3.14,
_parseonly):linear(64)\nrelu()\nlinear()concatof 4,000 inputsFull suite (
-m "not network and not benchmark", same machine, sequential runs): 3552 passed in 154.6 s ondevelop, 3569 passed in 35.9 s on this branch.Where statement handling lives (for the
for-loop PR). Each side has one dispatch table:Validator.STATEMENTSinsrc/hndl/_parser.py({ast.Expr: expr_statement, ast.Assign: assign_statement}, andValidator.statements(body)handles a nested body), and_Interpreter.STATEMENTSinsrc/hndl/config.py({"expr": ..., "assign": ...}, and_Interpreter.run(statements)handles a nested body). Addingfor _ in range(N):takes one handler on each side, plus addingforandinto_ACCEPTED_KEYWORDSin_parser.pyso the screen doesn't count them. Once loops unroll,max_nodesbecomes the only bound on graph size.Timing test.
test_many_concat_edges_stay_within_a_bounded_resolution_timeraced a 5 s subprocess timeout and was flaky under load. It now counts_Solver.set_shapecalls instead: 6n+9 for n = 4,000 edges, against a bound of 100n, where a pairwise comparison would need about n² = 16M. The check is deterministic and runs in process.2. Plan loading: readable diffs, and filling values the plan already determines
What it was.
validate_concrete_planre-resolves the saved arguments as explicit values and compared semantic digests. On any difference it refused with one line:E_INTEGRITY: Saved concrete arguments and port shapes are inconsistent; no inferred replacement is accepted.What it protected. Re-resolution only sees the saved args and the external contracts. So a digest difference means one of these:
finalize.Accepting (a) or (b), or (c) when a search fills the argument, could silently change the network a checkpoint was trained with. A policy or relation search can produce the same output shapes with different padding or kernels.
Why it's safe to relax. Only case (c) is relaxed, and only for the two ways the saved plan fully determines the value:
pretrainedplans from beforelayers=, and any argument added withoutsince=. The SPEC already requires a version bump when a default changes meaning.Arg(dim=...)argument. Its value is read off a saved port shape, and that shape is itself verified against the equations.In those cases the plan loads completed. Saved source and provenance are kept. A
UserWarningnames each filled value and gives the old and new semantic digest, so anyone who keys checkpoints by digest can see the change. Every other difference is refused as before, butE_INTEGRITYnow lists each mismatch, for example:A missing policy- or relation-chosen argument gives, for example,
node n0: in_channels is missing and would be chosen again as 3 (inferred); a saved plan never re-runs that choice. This builds on #81 rather than redoing it: anArg(since=...)default that a saved plan still spells out explicitly is reported as "dropped" and accepted.Digests. Both digests are still checked. The error now says what they detect:
Saved plan is corrupted or was edited by hand: its artifact_digest does not match its contents. Restore the file, or re-resolve the plan from its source; to change a plan, edit it in Python and save it with to_json().3. Resource limits
What it was.
DEFAULT_LIMITShas six keys and is plumbed through every entry point aslimits=.ResolvedPlan.from_jsonalso had a 16 MiB input cap and its ownmax_nodespre-check, andCapturehad a hard-coded copy of the 4,096max_nodesdefault with its own validation.Decisions.
limits=stays. It is public API documented in SPEC §5 and §7 for every entry point, and the tests use it. HyperGAN does not pass it (checked every.pyunder~/dev/hypergan). Removing the keyword from about a dozen signatures would break callers and remove no real complexity.max_state_bytesdefault goes from 1 GiB to 64 GiB. Evidence:embedding(50257, 1024)+ 24 ×transformer_block(16, causal=True)+linear(50257)has 405M parameters (1.6 GB), andbuild()refused it by default withE_RESOURCE: Plan exceeds max_state_bytes before allocation.pretrainednodes count toward the same bound, so a wrapped ViT-L hit it too. 64 GiB still catches a typo that asks for terabytes before anything is allocated.max_iterations(a relation that never settles),max_dimension/max_elements(huge-dimension typos),max_nodes(the only bound on graph size once loops unroll), andmax_edges(it stopsconcat(input_count=2**31-1)from materializing ports, seetest_huge_concat_input_count_rejected_before_materializing_ports).max_nodescheck (revalidation still appliesmax_nodesand every shape bound).Capturenow readsmax_nodesfrom_limits(limits), so there is one default and one validation.4. Other candidates, listed here and not changed
validate_concrete_planfully re-resolves on everybuild(), including plans the resolver just produced in the same process. A plan fromresolve()is resolved twice. This could be skipped for plans the resolver produced in process. I left it alone because it is in the build path nearperf/check-once.max_edgesexists mostly to cap a variadicinput_count. AnArg(max=...)oninput_countwould express that directly.MAX_PORTS,MAX_ARGUMENTS,MAX_SYMBOLS,MAX_NAME_LENGTH(operator.py), provider name length (registry.py), andMAX_OVERRIDES/MAX_PARAMETER_PATH(settings.py, which raiseE_RESOURCEoninit=/trainable=maps).resolve_graphrejects afrontendstring longer than 256 characters._contractrepeats themax_dimension/max_elementschecks that_Solver.set_shapedoes anyway.from_jsonrequires the exact field set, so any new optional field needs a schema bump.E_*codes.E_OUTPUT/E_OUTPUT_ARITY/E_CURRENTcould merge, andE_RESOURCEvsE_SCHEMAfor bad contracts is a judgement call.Tests changed because they asserted strictness removed on purpose
tests/test_config.py: dropped the"\n" * 4096→E_RESOURCEassertion (line limit removed). Removedtest_linux_isolation_and_no_unbounded_fallback,test_worker_invocation_is_isolated_for_strings_and_output_is_checkedandtest_timeout_and_worker_failure_are_explicit(worker removed). Addedtest_parsing_runs_in_process_on_any_platform, the pathological-nesting tests (in process and in a 256 KiB thread) andtest_valid_configs_are_not_limited_by_the_nesting_screen.tests/test_examples.py: the timing test now counts solver work (see above).tests/test_plans.py: replacedtest_persisted_omission_cannot_be_silently_reinferredwithtest_missing_arguments_the_plan_determines_are_filled_with_a_warningandtest_missing_arguments_only_a_search_would_choose_are_refused_with_a_diff. The forged-shape test now also asserts the diff line.tests/test_pretrained_provider.py:test_a_plan_saved_before_layers_existed_has_to_be_re_resolvedis now..._loads_completed, which also builds the completed plan.Checks
PYTHONPATH=src python -m pytest -q -m "not network and not benchmark": 3569 passed, 80 skipped, 53 deselected (35.9 s)ruff check src tests examples: cleanPYTHONPATH=src python -m hndl.docs --check: current🤖 Generated with Claude Code