Close extension API gaps for operators defined outside HNDL - #86
Merged
Merged
Conversation
- 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>
- 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>
Contributor
Author
|
Review follow-up (03627a5), all findings fixed:
Checks: full suite passed (3688 passed, 80 skipped, 53 deselected). 🤖 Generated with Claude Code |
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> * 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.
Closes three gaps found by writing a host package against the public API. Each one is tested in
tests/test_extension_api.pythe way an external package would use it: public imports only, operators registered onRegistry.builtins().1.
opsfollows the capture's registryBefore:
network_from_callable(fn, registry=reg)withops.my_op()insidefnfailed withE_OPERATOR: Unknown operator alias, because the globalopsalways looked aliases up in a freshRegistry.builtins().Design (
src/hndl/capture.py,OperatorNamespace):ops, inside a capture: each alias is checked againstcapture.registrywhen the attribute is read, so a typo still fails where it is written. The returned factory looks the alias up again every time it is called, so a factory read outside a capture (for examplelinear = ops.linearat module level) follows whichever capture it is called in.ops, outside a capture: aliases are still checked against the built-in catalog, and calling a factory still fails withE_CAPTURE. Both match current behaviour.registry.ops: unchanged. It is bound to its registry and takes the declaration when the attribute is read.Capture.emit: now requires the capture's registry to hold the same declaration object (_identities.get(op.key) is op). It used to callby_identityand then compare.keywith itself, a check that could never fail. The outcomes:opsandreg.opsin one capture works, because they resolve to the same declaration.reg2.ops.linearinside a capture ofregis allowed. EveryRegistry.builtins()shares the built-in declaration objects, so it binds exactly whatregwould. The docstring and SPEC now say this.reg2.ops.my_opinside a capture ofregfails withE_CAPTUREin two cases:reglacks the identity, orregdeclares the same identity with a different class. The message names the operator and says to pass the declaring registry, or to callops.<alias>when the capture's registry has that alias.Behaviour change: a foreign factory whose identity is missing used to fail with
E_STATE_VERSIONfromby_identity. It now fails withE_CAPTURE.tests/test_capture.py::test_registry_factories_require_selected_exact_identityis updated, and the CHANGELOG records the change.2. Public
hndl.relationssrc/hndl/relations.pyholdsconv_output,conv_transpose_output,spatial,elementwise_joinandbroadcast, now with docstrings. It is torch-free.conv_input_range: the interval inverse ofconv_output.conv_transpose_input: the exact inverse ofconv_transpose_output, orNoneif no input maps there.conv_axisandconv_transpose_axis: relate one axis ofxandout, in both directions.spatial()andconv1dare now built from these per-axis helpers instead of repeating the inverse arithmetic inline.spatial()rejects an unknownkindwithE_REGISTRYinstead of silently treating it as a forward convolution.conv1d's inverse error now says "extent" instead of "length". No test matched the old text.conv,deconv,conv1d,broadcast_add,broadcast_mul) import from..relations.hndl/operators/_relations.pyis a re-export shim, so existing imports of the private path keep working.docs/ADDING_OPERATORS.mdhas a new "Shared relations" section: a table of the helpers and a completedecimateoperator whose relation isconv_axis(s, 2, kernel=1, stride=factor). I ran that example and confirmed three behaviours: it passescheck_operator, it runs throughopsinnetwork_from_callable, and backward inference reportsE_AMBIGUOUSfor the 15-or-16 interval as the text says.3. Public
hndl.testingsrc/hndl/testing.pyis the formertest_all_operatorslogic, now taking a registry argument.check_declaration(registry, op)shape_textfor a relation.check_round_trip(registry, op, example)registry.opsgive the same digest. JSON round trip. Every arg is recorded except anArg(since=...)left at its default.check_build_and_run(registry, op, example, device=, dtype=)reprdraws no randomness.parameter_countsand receipt bytes. Output shape, dtype and finiteness. Finite input gradient. A same-seed rebuild reproduces the output.check_reference(registry, op, example, device=)reference=. ReturnsFalsewhen none is declared.check_operator(registry, alias, devices=None, dtypes=None, network=False)example_params,operator_paramspytest.params with the existingalias-indexids and thenetworkmark.example_cases,example_contract,example_input,available_devicesAssertionError, not bareassert, so they survivepython -Oand work outside pytest.example_params/operator_params.hndl/__init__does not importhndl.testing, soimport hndlpulls in neither pytest nor torch through it. A subprocess test asserts this.tests/operators/test_all_operators.pyis now thin wrappers overhndl.testing. It keeps the same parametrization and test ids, including the CUDA reduced-precision test.tests/operators/conftest.pykeeps onlyDEVICESand thedevicefixture.reprand rebuild-determinism checks that float32 already ran, so one function covers both. All pass on CUDA locally.ADDING_OPERATORS.md"Custom operators outside the package" gains three subsections: calling throughops, testing withhndl.testing, and evolving withArg(since=...).Docs
from hndl import ops ... fixed version bindingsparagraph is rewritten. The identity rules cover foreign factories. The relations sentence points athndl.relations. There is a new paragraph onhndl.testing, and two acceptance-gate rows are extended.ops.my_silu()works with the registry passed in, and mentionshndl.relationsandhndl.testing.check_operator.## Unreleasedsection.Tests
PYTHONPATH=src python -m pytest -q -m "not network and not benchmark": 3669 passed, 80 skipped (reference-less operators), 53 deselected. This ran on a machine with CUDA, so the float16/bfloat16 harness ran too.ruff check src tests examples: clean.PYTHONPATH=src python -m hndl.docs --check: current. No operator declarations changed, so the docs were not regenerated.tests/test_extension_api.py(24 tests) covers:opsagainst a custom registry inresolve_callableandnetwork_from_callable, with equal digests across config,opsandreg.ops.opsandreg.opsin one capture.opsoutside a capture, and late binding of a factory read there.Registry()capture, whereops.linearisE_OPERATOR.E_CAPTURE; a shared built-in is allowed.conv_axisrelation.check_operatoron a host operator that has anArg(since=...).Tradeoffs and open questions
E_STATE_VERSIONbecomesE_CAPTUREfor foreign factories.E_STATE_VERSIONdescribes restoring a plan, not authoring, and the old code path already intendedE_CAPTURE. Anyone matching on the old code will see a different one.opsonly knows the built-ins.my_op = ops.my_opat module import raisesE_OPERATOR; reading it inside the function, or usingreg.ops, works. Validating against nothing would lose early typo detection for the common case.hndl.operators._relationsis kept as a re-export rather than deleted. It is cheap, and code that imported the private path keeps working. It could be removed in a later minor release._pooling.pyhas its own window arithmetic (dilation 1, zero-stride normalization) and is left untouched to keep this PR scoped.example_paramsneeds thenetworkmark registered: a host package using it must register that mark in its pytest config, or pytest warns. The docs say so.🤖 Generated with Claude Code