Declarative decoder parameter schemas: pluggable realtime decoder configuration - #679
Conversation
…-backed storage Replaces the per-decoder typed-struct YAML traits and type-dispatch ladders in realtime config parsing with a declarative parameter-schema registry (cudaq/qec/decoder_config_schema.h). Each decoder registers a decoder_schema describing its custom args; a generic llvm::yaml CustomMappingTraits walker converts YAML <-> cudaqx::heterogeneous_map from the schema, including nested (subschema) and discriminated-union sections (schema selected by a sibling key, covering trt global_decoder_params and sliding_window inner_decoder_params with one mechanism). decoder_config::decoder_custom_args changes from a closed std::variant to a heterogeneous_map-backed wrapper; typed config structs remain as programmatic conveniences (assignment still works, reads via as<T>()). Out-of-tree decoder plugins can now make themselves realtime-ready by registering a schema from their own shared library instead of editing config.cpp. Consumer updates (tests, examples, python bindings) follow in this branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Ben Howe <bhowe@nvidia.com>
…ma tests Call sites move from std::variant access (std::get) to the map-backed wrapper (assignment of typed configs still works; reads via as<T>()). The example decoder plugin now registers a parameter schema from its own library, and new DecoderSchemaTest cases cover third-party registration, unknown-key and missing-required rejection, round-trip stability, and the plugin path. Python: decoder_config.decoder_custom_args becomes a property returning typed views for built-in decoder types and a dict for others; setter accepts typed configs or dicts. Adds config.decoder_param_schema()/registered_decoder_schemas() introspection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Ben Howe <bhowe@nvidia.com>
… registration Emission keeps the historical behavior for unknown decoder types (args dropped, now with a warning) so configure_decoders() still reports failures via status codes instead of throwing from to_yaml_str(). Input remains strict. Adds a docs subsection showing how a custom decoder plugin registers its parameter schema. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Ben Howe <bhowe@nvidia.com>
pymatching, chromobius, and trt_decoder schemas move from the central decoder_config_schema.cpp into their plugin .so files; single/multi_error_lut and sliding_window schemas move next to their decoder implementations in cudaq-qec-decoders. The central file now hosts only the nv-qldpc-decoder schema (plus its srelay_bp subschema) until the proprietary out-of-tree plugin registers it itself. Consequence: builds without a given plugin can no longer parse or emit that decoder's YAML section (arguably correct -- the decoder cannot run there either). trt-dependent YAML tests now skip when the trt schema is absent (GTEST_SKIP / pytest.mark.skipif via the new decoder_param_schema introspection), and the third-party schema test gains a synthetic discriminated + materialize_empty section so walker coverage no longer depends on the trt plugin being built. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Ben Howe <bhowe@nvidia.com>
sliding_window_config's three typed inner-params members collapse to a plain heterogeneous_map inner_decoder_params, and trt_decoder_config's global_decoder_config variant becomes std::optional<heterogeneous_map> -- both validated against the schema registry instead of hardcoded type lists. Deletes the global_decoder_config type, its four helper functions, and the variant special-casing in the python type casters; nested params surface as plain dicts in Python. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Ben Howe <bhowe@nvidia.com>
…ruth (option 2) Deletes srelay_bp_config, nv_qldpc_decoder_config, multi/single_error_lut_config, pymatching_config, chromobius_config, trt_decoder_config, and sliding_window_config along with their to/from_heterogeneous_map conversions and the INSERT_ARG/GET_ARG machinery. decoder_custom_args is a plain parameter map in C++ and a plain dict in Python -- the same representation for built-in and third-party decoders -- with keys governed solely by each decoder's registered schema. The Python decoder_custom_args getter no longer special-cases built-in types (the last per-decoder dispatch ladder), and the typed Python classes and their re-exports are removed. Docs now teach the dict/map + decoder_param_schema() introspection flow. Each decoder parameter now exists in exactly one place: the schema its decoder registers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Ben Howe <bhowe@nvidia.com>
Programmatically built configurations (heterogeneous_map / Python dict) never pass through the YAML parser, so schema checks (unknown keys, missing required keys, per-schema validate hooks) were only applied when parsing. Add validate_custom_args(schema_name, args) to the schema registry, expose it as decoder_config::validate_custom_args() and multi_decoder_config::validate_custom_args() (C++ and Python), and call it in the real_time_complete examples. Also register the first in-tree validate hook: sliding_window rejects step_size outside [1, window_size] and empty error_rate_vec at configuration time. Unknown-key and required-key checks remain framework provided; hooks are only for cross-field constraints the declarative specs cannot express. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Ben Howe <bhowe@nvidia.com>
decoder_config_json_schema() generates a JSON Schema (draft 2020-12) document for multi_decoder_config YAML files, so third-party tooling (check-jsonschema, python jsonschema, editor YAML language servers) can validate user configurations offline. Per-decoder decoder_custom_args sections are translated from the schemas registered at call time -- including out-of-tree plugins, whose schemas appear automatically once their library is loaded -- with required keys, additionalProperties: false, and discriminated-section dispatch mirroring the runtime parser. The fixed decoder_config envelope (id/type/block_size/H_sparse/...) is emitted alongside, and types with no registered schema accept no custom args, matching parse behavior. Exposed in Python as qec.decoder_config_json_schema(). Schema validate hooks are arbitrary code and are documented as not representable; the runtime parser remains authoritative. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Ben Howe <bhowe@nvidia.com>
- Apply schema-declared defaults (materialize_empty sections) to programmatically built configs: decoder_custom_args_to_heterogeneous_map now materializes via the registry, so a dict/map-built trt config with global_decoder but no global_decoder_params attaches the global decoder exactly like the YAML path. New materialize_default_args() in the registry; finalize_parsed_args delegates to it plus the canonical validate walk, eliminating the duplicated recursive schema walker. - Convert Python dict values to the schema's canonical types in the decoder_custom_args setter: ints are accepted for f64 params and negative ints for int32 params (the generic conversion stored every int as size_t, rejecting negatives at assignment and breaking f64 reads at emission); type mismatches raise an error naming the parameter. - Warn when YAML emission omits a map key absent from the registered schema (typo'd programmatic args no longer vanish silently). - Make custom-args equality sign-aware: size_t(2^64-1) no longer compares equal to int(-1). - decoder_config::from_yaml_str now rejects malformed YAML like the multi_decoder_config variant (pre-existing gap). - Cleanups: drop dead includes and the unused set_decoder_custom_args_from_heterogeneous_map, use heterogeneous_map::size(), document that the Python decoder_custom_args getter returns a copy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Ben Howe <bhowe@nvidia.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Ben Howe <bhowe@nvidia.com>
Bring in merged main, including the cuda_device_id GPU-pinning knob (NVIDIA#690) and its follow-up fixes (NVIDIA#678). NVIDIA#690's cuda_device_id additions (decoder_config field, YAML mapOptional, Python def_rw, the two YAML tests, and the realtime_decoding.rst example/prose) merge cleanly into the schema-registry config on this branch and are absorbed here. The docs conflict (this branch's decoder_custom_args schema section vs main's cuda_device_id section) is resolved by keeping both. Two branch-side adaptations NVIDIA#690 could not anticipate are layered on in the following commit: exporting cuda_device_id in this branch's decoder_config_json_schema(), and adapting NVIDIA#690's trt YAML test to the removed trt_decoder_config struct. Signed-off-by: Ben Howe <bhowe@nvidia.com>
Two branch-specific adaptations for the cuda_device_id knob (merged from NVIDIA#690) that NVIDIA#690 could not make, because they touch code that only exists on this branch: - config.cpp: export cuda_device_id in decoder_config_json_schema(). This JSON Schema export is a branch feature NVIDIA#690 predates; its envelope must stay in sync with MappingTraits<decoder_config> (enforced by the in-file contract), so the newly mapped key is added here. - test_decoders_yaml.cpp: adapt NVIDIA#690's PrepareDecoderParamsSurfacesCudaDeviceId trt case, which assigned a trt_decoder_config{} struct this branch removed. prepare_decoder_params only manipulates the params map (no schema or filesystem access), so empty custom args exercise the trt path equivalently. Signed-off-by: Ben Howe <bhowe@nvidia.com>
Signed-off-by: Ben Howe <bhowe@nvidia.com>
Resolve conflicts from PR NVIDIA#656 (num_boundary_syndromes) against the declarative decoder-schema refactor: - decoding_config.h / config.cpp: keep the schema-based machinery; the typed per-decoder config structs (incl. main's num_boundary_syndromes field on sliding_window_config) stay removed. - sliding_window.cpp: integrate num_boundary_syndromes into the sliding_window parameter schema (uint64) so the framework accepts the key the constructor already reads, and mirror the constructor's num_boundary_syndromes <= num_syndromes_per_round constraint in the schema validate hook. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Ben Howe <bhowe@nvidia.com>
The merge exposed num_boundary_syndromes as a sliding_window schema parameter but left the YAML/schema path untested (main only exercised it via the direct-decoder API). Add: - C++ (test_decoders_yaml.cpp): num_boundary_syndromes in a sliding_window YAML round-trip, plus validate-hook cases asserting num_boundary_syndromes > num_syndromes_per_round is rejected. - Python (test_decoders_yaml.py): a serialization-only round-trip test for num_boundary_syndromes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Ben Howe <bhowe@nvidia.com>
The schema branch removed nv_qldpc_decoder_config and friends outright, making the PR a breaking change for Python users. Reintroduce every previously bound class as a pure-Python shim (cudaq_qec/_compat.py) that wraps a dict of explicitly-set parameters and warns with DeprecationWarning on construction. Old code that builds a typed config and hands it to set_decoder_custom_args / decoder_custom_args works unchanged: the binding layer duck-types on to_heterogeneous_map() and converts the shim's dict through the schema named by the shim itself, so conversion no longer depends on decoder_config.type having been assigned first. The shims reproduce the old semantics exactly: every field acts like the std::optional it used to be (unset reads as None, None clears, only set fields are emitted), sliding_window's three typed inner-params fields collapse into one inner_decoder_params section with the old first-set priority, and trt's global_decoder_params accepts the pymatching / chromobius shims. Reading decoder_custom_args still returns a plain dict; that (and the removed C++ structs) remains the only breakage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Ben Howe <bhowe@nvidia.com>
Bring back the typed-config Python tests this branch removed, in a new test_decoding_config_deprecated.py (deletable together with _compat.py when the deprecation period ends). All restored tests run verbatim except the four whose read-side asserts touch the one intentional API change -- decoder_custom_args reads back as a plain dict, never a typed object -- and those edits are marked inline. The shim-specific tests from the previous commit move into the same file. Three fidelity fixes in _compat.py let the originals pass unmodified: - Attribute assignment type-checks values against the decoder's registered schema kinds and raises TypeError like the old nanobind setters did (skipped when the plugin/schema is not loaded; list contents are only inspected for list/tuple so array-likes still pass through to the conversion layer). - trt_decoder_config materializes a default typed global_decoder_params for a recognized global_decoder, at from_heterogeneous_map and in the emitted map, matching the old struct's defaulting. - trt_decoder_config.from_heterogeneous_map rejects global_decoder_params for a global_decoder the typed API never recognized, as the old code did. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Ben Howe <bhowe@nvidia.com>
melody-ren
left a comment
There was a problem hiding this comment.
Thanks Ben! Finally got rid of that ever changing config.cpp way of defining config. Good riddance.
Most likely not in scope for this PR, I think we still need a pre-decode job validator in addition to the schema checker such that we won't need live decoders to verify the config. That will be a bigger change and touching decoders too.
Regarding round-trip and config generation/verification: I'm not sure if the intended behaviour is to have a single source of truth for the config. Right now there are several paths for the config to reach the decoders. It can go through the realtime parsing, programmatic configure_decoders(), offline get_decoder (I might even be missing some). These paths do not share the same validation or normalization and might lead to a hole down the road (lesson learned from patching the pinning hardware PR). If unification is desired, it can go into a follow up though.
This is also related to round-trip. For example, a programmatic TRT config containing only global_decoder: pymatching does not contain global_decoder_params when first serialized, but after YAML round-trip it gains global_decoder_params: {}, so the objects and emitted YAML are unequal. The realtime decoder behaviour is still preserved because the constructor-facing path materializes the empty params before TRT construction; the difference is in config serialization, and the old typed YAML path included the empty section on the first emission. Similarly, a plugin field omitted from its registered schema can reach a local decoder unchanged but be dropped when the same configuration is serialized for a remote target. Some of the behaviour might be leftover from before and this PR or a follow up might be a good spot to clean up those "slightly different depending how you use it" behaviour.
Decoder plugins are dlclose'd from a library destructor at process exit (cleanup_decoder_plugins). The schema registry was a destructible function-local static, so destroying it during static destruction could run validate-hook std::function destructors whose code lives in an already-unloaded plugin .so. Heap-allocate the registry and its mutex and intentionally never free them, the same pattern as get_plugin_handles() and INSTANTIATE_REGISTRY. Schemas are deliberately never unregistered; document that policy (and the now-guaranteed find_decoder_schema pointer lifetime) in the header. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Ben Howe <bhowe@nvidia.com>
validate_custom_args only checked key membership, required keys, and the per-schema hook -- a value of the wrong type (e.g. a string stored under an f64 parameter) passed validation and then failed later at YAML emission with a low-context error. Now every present value must be readable as its kind's canonical storage type, so a map that validates is guaranteed to serialize (round-trip invariant). The check probes the stored std::any by pointer against T and the same RelatedTypesMap<T> tuple heterogeneous_map::get<T> iterates, so it accepts exactly what emission and decoder construction accept -- by construction, not by a mirrored list -- and never copies a value. Nested-section recursion also switches from get<heterogeneous_map> (which copied the sub-map) to the pointer form. This also gives a clear key-naming diagnostic for the Python footgun of assigning a dict before decoder_config.type: the generic conversion stores ints as size_t, which an f64 parameter cannot read back, and validate_custom_args now says so instead of leaving it to emission. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Ben Howe <bhowe@nvidia.com>
A programmatically built config previously diverged between its two
consumers: non-schema keys reached a local decoder's constructor but
were warned-and-dropped from emitted YAML (so a remote target never saw
them), and trt's defaulted global_decoder_params appeared only after a
YAML round trip, not on first emission.
decoder_custom_args_to_heterogeneous_map() now applies one
normalization -- drop_non_schema_keys (warn per key; unknown keys could
never round-trip through YAML, and the old typed structs ignored them
everywhere) followed by materialize_default_args -- and YAML emission
serializes that same map. A trt config with only global_decoder set
emits global_decoder_params: {} on first emission, matching the old
typed path, so emitted YAML is stable across round trips. The stored
decoder_custom_args are untouched, and validate_custom_args() still
hard-rejects unknown keys for callers who want an error instead of the
warn-and-drop.
drop_non_schema_keys recurses into nested sections in place and leaves
the map untouched on the common all-keys-known path; only a map that
actually loses a key is rebuilt.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Ben Howe <bhowe@nvidia.com>
Agreed that a full pre-decode job validator is out of scope here, but a chunk of it already works without live decoders:
What genuinely needs the bigger change you're describing is validation that requires runtime context - H/O/D dimensions vs. block_size/syndrome_size vs. parameter values (e.g. error_rate_vec length), resource/capability checks, etc.
Partially fixed on this branch now: the realtime paths are unified. Both YAML parsing and programmatic The path that's still outside the fence is offline
I don't think we can ever get all the way down to a single source other than the live decoders, if for no other reason than that some decoding configurations may require resources that are able to be satisfied on some systems but not others. (I.e. perhaps some decoding configurations require 100 SMs but some GPUs don't have that many.) Therefore, all the additional validation checks are "just gravy" in my opinion, meant to catch as many errors as they can, but they are ultimately just "best effort" validation.
Both examples are fixed in the latest commits. Emission now serializes the same normalized map the constructor-facing path produces (defaults materialized, non-schema keys warned-and-dropped), so:
|
…oder configuration (NVIDIA#679)" This reverts commit 35ee248. Signed-off-by: Angela Burton <angelab@nvidia.com>
Declarative decoder parameter schemas: true third-party support for realtime decoding
TL;DR
config.cppwas a closed shop: every decoder's YAML parameters were hard-coded intotyped structs, a
std::variant, and per-type LLVM YAML traits inside the core library.A third-party decoder could implement the
decoderinterface from its own plugin, butcould never be configured through the realtime YAML without patching and rebuilding
CUDA-Q QEC.
This PR replaces all of that with one declarative schema per decoder, registered by the
same shared library that registers the decoder itself. The framework does the rest —
parsing, emission, validation, defaulting, Python conversion, and standalone JSON Schema
export are all generic. The old typed Python config classes remain available as
deprecated compatibility shims, and the pre-schema typed-config test suite runs against
them essentially unmodified. Net size: +3.9k/−2.2k lines, and the additions are
mostly tests, docs, and the deprecation layer; the core config machinery is smaller than
what it replaces.
Headline benefits
🔌 True third-party realtime decoders. A plugin makes itself fully YAML-configurable
with one registration in its own
.so— zero framework changes, zero rebuilds:Proven end-to-end: a demo decoder was built against a sandbox
cmake --installtreeusing only installed headers, dropped into
decoder-plugins/, and ran realtime decodingwith its own YAML parameters — no cudaqx source edits.
🧰 Maintainability: each parameter exists in exactly one place. Previously, adding
one decoder parameter meant touching ~6 places (struct field, variant traits, YAML
mapping,
to_heterogeneous_map, Python binding, docs). Now it is one line in thedecoder's own schema. The core gets rid of ~900 lines of per-decoder ladders, and the
YAML walker, validator, and JSON exporter never change when decoders are added.
🐍 Usability: plain dicts and maps, with introspection. No more per-decoder config
classes to learn:
Values are converted to the schema's declared types (Python ints are fine for float
parameters, negative ints for int32 parameters), and mistakes fail with messages like
Parameter 'bp_seed' of 'nv-qldpc-decoder' expects a 32-bit int value.✅ Validability, three ways.
Parse time: unknown keys and missing required keys are rejected by the framework —
decoders never write those checks.
Programmatic:
config.validate_custom_args()applies the same checks to dict/map-builtconfigs before use; decoders can attach cross-field hooks (e.g. sliding_window rejects
step_size > window_sizeat config time instead of deep in the constructor).Offline:
qec.decoder_config_json_schema()exports a standard JSON Schema(draft 2020-12) built from whatever plugins are loaded, so CI and editors can lint
config files without loading the library at all:
🔁 Behavior preserved. YAML round-trips byte-match the old output; trt's default
global_decoder_paramsmaterialization, sliding_window's inner-decoder handling, andconfigure_decodersstatus-code semantics are unchanged and covered by tests (31 C++ /93 Python in the touched suites, all green).
Compatibility
Python: backward compatible, with deprecation warnings. The typed config classes
(
nv_qldpc_decoder_config,trt_decoder_config,sliding_window_config,pymatching_config,chromobius_config, etc.) remain available as pure-Python,dict-backed shims (
cudaq_qec/_compat.py) that emit aDeprecationWarningonconstruction. Existing code works unchanged:
The shims reproduce the old semantics: optional fields (unset reads as
None, only setfields are emitted),
TypeErroron wrongly-typed assignments, sliding_window's typedinner-params fields collapsing into
inner_decoder_params, trt's defaultglobal_decoder_paramsmaterialization, and nested typed configs throughout. A shimcarries its own schema name, so unlike the dict path it converts correctly even before
decoder_config.typeis assigned.Proof: the pre-schema typed-config test suite is restored verbatim in
test_decoding_config_deprecated.pyand passes against the shims — the only edits arefour tests whose final asserts read configs back (the one intentional API change, see
below), each marked inline, plus one smoke test that used no typed config and lives on
in the dict-based suite. The file notes it should be deleted together with
_compat.pywhen the deprecation period ends.
What does break:
std::variant decoder_custom_args, and theirYAML traits are removed with no shim. C++ callers build a
cudaqx::heterogeneous_map(the form every decoder constructor already consumes) and assign it directly.
decoder_config.decoder_custom_argsnow always returns a plain dict,never a typed object — code that read typed fields off a parsed config
(
dc.decoder_custom_args.max_iterations) must index the dict instead.after the removal (e.g. sliding_window's
num_boundary_syndromes) are dict-only.One structural consequence to be aware of: a decoder's YAML section is only parseable when
its plugin (and therefore its schema) is loaded. Hosts that parse-and-forward configs for
decoders they don't have locally need that decoder's plugin present. The nv-qldpc-decoder
schema is temporarily hosted in-tree until the proprietary plugin registers it itself.
Follow-ups (out of scope here)
sparse_binary_matrixparameter kind with scipy pass-through (parked on a separatebranch for its own PR).
global_decoder) to decoder-roleschemas rather than any registered name.
🤖 Generated with Claude Code