Cross-Binding Parity Gate¶
How scripts/check_binding_parity.py makes "I added a function to the C ABI but forgot the pybind11/NAPI wrapper" a CI failure instead of a silent gap that surfaces months later when a user complains.
The problem it solves¶
The C ABI surface (lrvx_capi.h) grows whenever someone adds a LRVX_EXPORT to the IDL. Each binding (pybind11, NAPI, Codon, QuickJS) is supposed to expose that addition, and all four are hand-written, so all four drift.
The gate used to promise this and not deliver it. It matched class names in .pyi / .d.ts, so a single name satisfied a whole group however many functions the group gained; it checked Codon against tools/codegen/golden/lrvx_capi.codon, a file generated from the same IDL the gate had just parsed, which cannot disagree with it; and it skipped QuickJS for any group that carried no quickjs entry, which was 59 of the 73 groups. All three holes are closed: parity is now checked per C function.
How it works¶
The script:
- Parses the IDL (
include/lrvx/capi/lrvx_capi_spec.hpp). EveryLRVX_EXPORT(group = "X")declaration belongs to a named group. - Reads the manifest (
tools/codegen/binding_parity.yaml). Each group has four entries:pybind11,napi,codon,quickjs. - Asks, for every function of the group, whether that binding can reach it:
- pybind11: a call site under
python/(C/C++ sources, comments stripped) - NAPI: a call site under
node/src/ - Codon: a
from C import lrvx_xin a module undercodon/lrvx/— the code a strategy links, not the generated golden - QuickJS: a
lrvx_x: __lrvx_xentry in the group'sfunctions:map, whose global is registered withaddGlobalFuncinsrc/quickjs/js_bindings.cpp - Checks the declared names too: the
classes:of arequiredentry must appear in.pyi/.d.ts/ the QuickJS prelude, and afunctions:list underpybind11/napistill names top-level functions in those stubs. QuickJS classes are read throughlrvx_codegen.manifest.scan_quickjs, the same extractorscripts/sync_mcp_data.pyuses for the MCPbinding_manifest, so the two never diverge. - Fails loudly on a function that is neither reachable nor allowlisted.
Run locally:
python3 scripts/check_binding_parity.py # exit 1 on drift
python3 scripts/check_binding_parity.py --verbose # show every group, not just failures
python3 scripts/check_binding_parity.py --root /path/to/tree
Wired into CI as the "Verify cross-binding parity" step inside the verify-docs-current job, with --require-quickjs passed explicitly, plus a step running pytest scripts/tests -q — the gate's own tests, which build a miniature repository, delete one wrapper, and require the gate to go red.
The QuickJS map¶
A QuickJS required entry maps each C function to the global a strategy calls:
composite_book:
quickjs:
status: required
classes: [CompositeBook]
functions:
lrvx_composite_book_create: __lrvx_cb_create
lrvx_composite_book_destroy: __lrvx_cb_destroy
The globals are written out rather than derived because they are not derivable: the __ + C-name convention has real exceptions (__lrvx_vprofile_create wraps lrvx_volume_profile_create, __lrvx_cb_* abbreviates lrvx_composite_book_*). --require-quickjs is the default, so every group carries an entry; --no-require-quickjs exists for a tree where the QuickJS layer is not built.
The per-function allowlist¶
A binding that has no wrapper for a function carries it in the top-level allowlist_functions: section, under that binding, with a one-line reason:
The reason is mandatory — an entry with an empty reason fails the gate. Shared reasons are YAML anchors defined once in allowlist_reasons: above the list; a one-off gap can carry its own text. Remove the entry when the wrapper is written.
The group's status stays required: demoting a group to allowlist would hide every function in it, including the ones added tomorrow. This way a new LRVX_EXPORT still fails the gate in all four bindings until someone either writes the wrapper or writes down why there isn't one.
What each binding does not reach¶
Counts as of the change that introduced the function-level rule, out of 736 C functions:
| binding | reachable | allowlisted | what the gap is |
|---|---|---|---|
| pybind11 | 55 | 681 | the Python binding wraps the C++ API directly rather than calling the C ABI, so most entry points have no call site under python/ at all |
| napi | 585 | 151 | individual wrappers never written |
| codon | 566 | 170 | functions no module under codon/lrvx/ imports |
| quickjs | 524 | 212 | 208 with no addGlobalFunc registration; 4 called only inside other wrappers, with no global of their own |
allowlist_functions: is the source of truth for these numbers — this table is a summary and will age. The pybind11 figure is not a claim that Python cannot do those things: it says the Python binding is a parallel implementation over the C++ headers, so fixes made at the C boundary (NULL-safety, the exception guard, the error accessors) do not reach it.
The struct-layout tables¶
The Codon binding reads event payloads out of the C structs by byte offset, because the codegen models every aggregate as an opaque cobj. Those offsets used to be typed in by hand in codon/lrvx/dispatch.codon and codon/lrvx/strategy.codon, with nothing connecting them to the header.
They are generated now, by tools/codegen/lrvx_codegen/emit_layout.py, from the same IDL spec the C header comes from:
include/lrvx/capi/lrvx_capi_layout.h—kLrvxEventLayout(struct, field, offset, width) andkLrvxEventStructLayout(struct, size, alignment)codon/lrvx/layout.codon— the_TRADE_*/_BAR_*/_EV_*/_CTX_*/_LRVXBAR_*constants the Codon modules import
Both are regenerated by tools/codegen/scripts/regenerate.sh and diffed in place by tools/codegen/scripts/check.sh and the codegen workflow, so editing either by hand is drift and fails CI. tests/test_capi_event_layout.cpp closes the loop at the other end: every entry is compared against offsetof / sizeof / alignof on the real struct, so an offset the compiler disagrees with fails the build rather than delivering a wrong number to a strategy.
Status values¶
Each per-binding entry has one of three statuses:
required¶
The binding must expose the listed symbols. List them under classes or functions:
metrics:
pybind11: { status: required, classes: [PnLTracker] }
napi: { status: required, classes: [PnLTracker] }
codon: { status: required }
This is the default for every user-facing surface. If the listed class or function disappears, CI fails — and so does a C function of the group that the binding cannot reach and that carries no allowlist_functions: entry.
not_applicable¶
This group is internal helpers and never intended for this binding. No symbol list:
fixed_point:
pybind11: { status: not_applicable } # exposed via Price.toDouble() on classes
napi: { status: not_applicable }
codon: { status: required }
Used for things like pointer_out (output-pointer helpers) or validation (input validation primitives) that wouldn't make sense as a Python class.
allowlist¶
The whole group is a known gap, with a mandatory reason:
your_group:
napi:
status: allowlist
reason: "stats functions exposed via Stats class methods; function-style not declared."
Use this only when the binding exposes nothing of the group at all. For a group the binding does expose, with individual functions missing, keep required and list those functions under allowlist_functions: — a group-level allowlist also swallows every function added to the group later.
Adding a new IDL group¶
If you add LRVX_EXPORT(group = "new_thing") and don't update the YAML, CI says:
Add a stanza for the new group:
new_thing:
pybind11: { status: required, classes: [NewThing] }
napi: { status: required, classes: [NewThing] }
codon: { status: required }
quickjs:
status: required
functions:
lrvx_new_thing_create: __lrvx_new_thing_create
Then make sure the listed classes exist on each side, and that every function of the group is either reachable from each binding or listed under allowlist_functions: with a reason.
Removing a group¶
If you delete the last LRVX_EXPORT(group = "old_thing") declaration, the gate notices the YAML still mentions it:
Remove the YAML stanza in the same PR.
Why this is just a manifest, not auto-generation¶
Reasonable question: "if you know which classes belong to which group, why not generate the bindings from that?"
Two reasons:
- The manifest knows the names, not the shape. It can say "
Executorshould exist", but it can't generate thesubmit / cancel / replace / submit_oco / capabilitiesmethod signatures — those are codegen's job (and codegen is unsafe to run for pybind11/NAPI for perf reasons). - Coverage ≠ correctness. Even if
Executoris named in.pyi, that doesn't mean the binding actually wireslrvx_executor_*correctly. Tests check correctness; the gate checks "did you remember to write the wrapper at all".
So the gate is a coarse mechanical check: "does the symbol exist?". Tests cover the semantics. Together they catch most of what auto-generation would catch, without giving up idiomatic bindings.
Scope of the gate (what it doesn't catch)¶
- Argument signature drift (pybind11 method takes
intbut C ABI takesint64_t). That's pybind11's responsibility at runtime. - Submodule-style exposure (
lrvx.targets.linear_sloperather than top-levellrvx.LinearSlope). The gate only inspects top-level declarations forclasses:/functions:. Thetargetsgroup isallowlist'ed for this reason. - Whether a call site is correct. The gate sees that
python/mentionslrvx_x; it does not see whether the arguments are right. Tests cover that. - Behavioral parity (Python and Node returning different values for the same input). Cross-binding parity tests (
scripts/cross_binding_parity.py) cover that.
Extending the gate¶
The script is small (scripts/check_binding_parity.py). If you need a new check, add it there. Examples:
- QuickJS registration, not just parity.
check_quickjs_registration.pyis a sibling gate: it asserts that every__lrvx_*global the JS layer calls has anaddGlobalFuncregistration, which is whatsetQueueFifoTopNviolated. That is a different question from this gate's "does the IDL group's promised symbol exist" — this gate would pass on a registered-but-never-called global, andcheck_quickjs_registration.pydoesn't know about IDL groups at all. - Method-level checks. Currently we check class presence; we could check that a class has specific methods (e.g.
Executormust havesubmit,cancel,replace).
Both would tighten the gate. Keep changes minimal — every false-positive case wastes contributor time.
Sibling gate: behaviour, not names¶
This gate asserts a symbol exists. Every defect the binding-surface audit
found had an existing symbol — VenueStack.clock() raised "Unregistered type",
the five Node VenueStack factories threw SyntaxError.
check_binding_smoke.py closes that by
walking both bindings by reflection and calling what it finds (~760 zero-arg
calls), failing only on binding-level errors and ignoring rejected inputs.
A binding that cannot be imported fails that gate. It used to print
python: SKIP (cannot import lrvx: ...) and exit 0, so a wheel that did not
load and an addon that was never built both read as "OK: no binding-level
failures" — the gate was loudest exactly when it had exercised nothing. An
import failure, a Node entry point that throws, and a missing node/index.js
are all reported with the binding named and a non-zero exit. --root points it
at another tree, the way the parity gate's --root does.