Skip to content

Declarative decoder parameter schemas: pluggable realtime decoder configuration - #679

Merged
bmhowe23 merged 23 commits into
NVIDIA:mainfrom
bmhowe23:decoder-config-schema
Jul 15, 2026
Merged

Declarative decoder parameter schemas: pluggable realtime decoder configuration#679
bmhowe23 merged 23 commits into
NVIDIA:mainfrom
bmhowe23:decoder-config-schema

Conversation

@bmhowe23

@bmhowe23 bmhowe23 commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

Declarative decoder parameter schemas: true third-party support for realtime decoding

TL;DR

config.cpp was a closed shop: every decoder's YAML parameters were hard-coded into
typed structs, a std::variant, and per-type LLVM YAML traits inside the core library.
A third-party decoder could implement the decoder interface from its own plugin, but
could 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:

register_decoder_schema({"my_decoder", {
    {"strength", k::f64},
    {"mode",     k::string, /*required=*/true},
}});

Proven end-to-end: a demo decoder was built against a sandbox cmake --install tree
using only installed headers, dropped into decoder-plugins/, and ran realtime decoding
with 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 the
decoder'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:

config.decoder_custom_args = {"error_rate_vec": rates, "merge_strategy": "smallest_weight"}
qec.decoder_param_schema("pymatching")   # discover any decoder's parameters at runtime

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-built
    configs before use; decoders can attach cross-field hooks (e.g. sliding_window rejects
    step_size > window_size at 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:

    python3 -c "import cudaq_qec; print(cudaq_qec.decoder_config_json_schema())" > schema.json
    check-jsonschema --schemafile schema.json my_config.yaml

🔁 Behavior preserved. YAML round-trips byte-match the old output; trt's default
global_decoder_params materialization, sliding_window's inner-decoder handling, and
configure_decoders status-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 a DeprecationWarning on
construction. Existing code works unchanged:

cfg = qec.nv_qldpc_decoder_config()   # DeprecationWarning
cfg.max_iterations = 50
dc.set_decoder_custom_args(cfg)       # same YAML as the dict form, byte-for-byte

The shims reproduce the old semantics: optional fields (unset reads as None, only set
fields are emitted), TypeError on wrongly-typed assignments, sliding_window's typed
inner-params fields collapsing into inner_decoder_params, trt's default
global_decoder_params materialization, and nested typed configs throughout. A shim
carries its own schema name, so unlike the dict path it converts correctly even before
decoder_config.type is assigned.

Proof: the pre-schema typed-config test suite is restored verbatim in
test_decoding_config_deprecated.py and passes against the shims — the only edits are
four 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.py
when the deprecation period ends.

What does break:

  • C++: the typed config structs, the std::variant decoder_custom_args, and their
    YAML traits are removed with no shim. C++ callers build a cudaqx::heterogeneous_map
    (the form every decoder constructor already consumes) and assign it directly.
  • Python reads: decoder_config.decoder_custom_args now 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.
  • The deprecated classes expose exactly the old attribute surface; parameters added
    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)

  • Move nv-qldpc-decoder / srelay_bp schema registration into the proprietary plugin.
  • sparse_binary_matrix parameter kind with scipy pass-through (parked on a separate
    branch for its own PR).
  • Optionally constrain discriminated sections (e.g. global_decoder) to decoder-role
    schemas rather than any registered name.
  • Remove the Python deprecation shims (and their test file) after one release cycle.

🤖 Generated with Claude Code

bmhowe23 and others added 10 commits July 11, 2026 04:54
…-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>
@bmhowe23
bmhowe23 marked this pull request as draft July 11, 2026 05:34
bmhowe23 added 3 commits July 14, 2026 01:29
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>
Comment thread libs/qec/lib/decoders/sliding_window.cpp
bmhowe23 and others added 6 commits July 14, 2026 16:36
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>
@bmhowe23
bmhowe23 marked this pull request as ready for review July 15, 2026 17:01

@melody-ren melody-ren left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread libs/qec/include/cudaq/qec/decoder_config_schema.h
Comment thread libs/qec/lib/decoder_config_schema.cpp
bmhowe23 and others added 4 commits July 15, 2026 19:59
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>
@bmhowe23

Copy link
Copy Markdown
Collaborator Author

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.

Agreed that a full pre-decode job validator is out of scope here, but a chunk of it already works without live decoders:

  • Cross-field checks with no decoder instantiation: each schema can attach a validate hook that runs at parse time and from validate_custom_args() - sliding_window uses it today (step_size <= window_size, non-empty error_rate_vec), and any plugin can register one from its own .so. Nothing is constructed to run these.
  • Value-kind checking (added on this branch after your comment): validate_custom_args() now verifies every value is readable as its declared kind, so a config that validates is guaranteed to serialize - again with no decoder in the loop.
  • Offline tooling: One could use decoder_config_json_schema() to generate a JSON schema which can be used to validate any YAML file against that schema. Granted, it doesn't have the validate hook, but it does cover some kind of document-style validation that requires no special hardware and no special CUDA-Q QEC libraries.

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.
Today those live in the decoder constructors, so a pre-decode validator would need decoders to expose something like a static validate(config) alongside construction. That touches the decoder API and every implementation, so I would indeed rather save that for later.

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.

Partially fixed on this branch now: the realtime paths are unified. Both YAML parsing and programmatic configure_decoders() run the same normalization (unknown keys warned-and-dropped, defaults materialized, values checked against their schema kinds), and emission serializes exactly the map the decoder constructor receives - so your trt example now emits global_decoder_params: {} on first emission and round-trips stably.

The path that's still outside the fence is offline get_decoder(name, H, opts), which takes a raw options map and never consults the schema. Agreed that unifying it is a potential follow-up - it changes behavior for existing callers and existing decoders, so it deserves separate thought.

I'm not sure if the intended behaviour is to have a single source of truth for the config.

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.

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.

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:

  • the trt config emits global_decoder_params: {} on the first emission, matching the old typed path, and emitted YAML is byte-stable across round trips;
  • a non-schema key can no longer reach a local decoder while silently vanishing for a remote target - it's dropped (with a warning) from both, and validate_custom_args() hard-rejects it for callers who want an error instead.

@bmhowe23
bmhowe23 enabled auto-merge (squash) July 15, 2026 21:44
@bmhowe23
bmhowe23 merged commit 35ee248 into NVIDIA:main Jul 15, 2026
38 of 40 checks passed
anjbur added a commit to anjbur/cudaqx that referenced this pull request Jul 16, 2026
…oder configuration (NVIDIA#679)"

This reverts commit 35ee248.

Signed-off-by: Angela Burton <angelab@nvidia.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants