ADR-066: Deterministic Stub Generation — experimental-inspect + pyo3-introspection retire the hand-maintained .pyi set¶
Status: accepted (architecture). Decided at the architecture-review grilling. The stub-diff spike is the first implementation gate and reopens this decision only if generation proves unable to cover the seam surface.
Context¶
ADR-053 evaluated pyo3-stub-gen — a proc-macro rewriter of the seam’s source —
rejected it, and kept the 27 hand-maintained stub files under src/degenbot/_ffi/,
policed by mypy.stubtest (tests/ffi/stubtest_allowlist.txt) and a bespoke AST drift
gate. The ecosystem has since moved: PyO3’s experimental-inspect feature (documented
at pyo3.rs v0.29.2) embeds introspection data in the built cdylib, and the first-party
pyo3-introspection crate (0.29.2, released in lockstep with the workspace’s
pyo3 ^0.29) reads it back — introspect_cdylib(path) returns the module model and
module_stub_files() emits the root __init__.pyi plus per-submodule stubs: exactly
the artifact shape the seam hand-maintains today.
The governing principle from the same design session: wherever deterministic generation is available, use it. A Rust-only consumer and a Python driver must reach the core on equal footing, and the PyO3 layer stays minimal — hand-written seam artifacts are a third place for drift to hide.
Decision¶
D1 — A named stub-generation lane. A build of degenbot_rs with pyo3’s
experimental-inspect feature exists solely to be introspected; a just recipe runs
introspect_cdylib over that build and writes the stub files. Release wheels exclude
the feature, per the release-feature policy.
D2 — Generated stubs are committed, with a regenerate-and-diff gate. The stub set
stays in the tree (Python imports need it beside the compiled module), but source and
stubs may only agree: the REGEN_*-pattern drift gate already used for
docs/rust-config-keys.md fails on any diff.
D3 — The existing gates become generator verification. mypy.stubtest against the
running extension and the AST drift gate stay in place, re-read as checks on the
generator’s output. Generator gaps live in tests/ffi/stubtest_allowlist.txt and only
there — nobody hand-edits a generated stub on top, because that re-enters the drift
blind spot this ADR closes.
D4 — The principle spans the seam. The typed configuration surface owed to
ADR-065 (a named-property projection over the verdict, replacing dotted-path string
lookups) is emitted from the config_schema! declaration site rather than hand-written,
and the dead seam doors beside it (verification_retry_policy_defaults, the duplicate
discovery_batch_size clamp) close. ADR-053’s pyo3-stub-gen rejection stands; its
“the hand-maintained stub set stays” decision is superseded.
Consequences¶
The first implementation step is the spike the decision names: generate against today’s cdylib and diff the output over the 27 hand-maintained files. That delta is precisely the future contents of the allowlist — and the honest measure of how far annotation generation still is (PyO3 calls it “a first step”; issue #2454 tracks the rest).
pyo3-introspectiontrackspyo3in version lockstep, so the generator pin follows the workspace’spyo3pin; apyo3bump implies a generator bump and a fresh diff.The pure-Rust consumer is untouched: stubs are a Python-visible artifact only.
Alternatives considered¶
Keep hand-writing (ADR-053’s path). Rejected: the drift gates exist to police human transcription work a deterministic tool now performs.
pyo3-stub-gen. Stays rejected per ADR-053 — it rewrites the seam’s source via proc macros instead of introspecting the built artifact; it was never this proposal.
Gate outcome — the first spike¶
The stub-diff spike measured the mechanism against the real seam: the generator
(pyo3-introspection 0.29.2, audited) works, introspection data embeds correctly,
but pyo3-macros-backend’s fn-based #[pymodule] expansion passes empty member
lists and the incomplete flag, and this seam registers imperatively
(PyModule::new + add_function + add_submodule) at ~27 sites. Result: 27 of
27 deltas are generator-gap at the module-structure level; zero generator-correct,
zero generator-broken.
The decision therefore stands, with a named prerequisite now visible: the
registration surface must move to declarative #[pymodule] mod form (tracked as
an implementation task). The post-conversion re-gate is folded into that task’s
acceptance: if generation still cannot cover the seam after conversion, this ADR
reopens under its own terms.
Post-conversion re-gate: the decision stands. The root module introspects 28
classes, 36 functions, and all 26 declarative submodule trees with reachable
members — 27 generated stubs, 1:1 with the hand tree, incomplete=false on 24 of
26. Classification: generator-correct in bulk (including catching ten runtime
members of degenbot._ffi.simulation missing from the hand .pyi, soaked today
by the stubtest allowlist), generator-gap confined to the create_exception!
island types, imperative U256 attributes, the #2454 annotation frontier,
__all__ emission, and __new__-vs-__init__ representation; generator-broken:
zero. The stub-generation lane builds with the gap list as the allowlist’s origin.