~bigbes/tarantool

tarantool-protobuf

5c7055577dedb31f1c4817f02ec5ad6f65a068b5 — Eugene Blikh 2 months ago 3042384
c-accel: descriptor -> C plan compiler (bd-mq7)

First C source for the pb.c_runtime module. Compiles a finalized Lua
descriptor into an opaque pb_plan userdata, the foundation that
bd-ra6's encode/decode entry points will walk.

What the plan carries (per docs/specs/c_accel_strategy.md):

  * Per-field records: field_number, wire_type, kind, repeated/packed/
    optional flags, pre-encoded tag bytes (varint, up to 5 bytes),
    sub_plan_idx (1-based into the sub-plans table), enum_ref
    (luaL_ref for enum descriptor), oneof_idx back-pointer.
  * Map fields capture map_key_kind, map_value_kind, and the value's
    sub_plan_idx when the value is a message.
  * Oneofs as a parallel array of {name, member_indices[]}, with
    fields' oneof_idx pointing back to their group.
  * WKT override detection: when desc.encode/desc.decode are set, the
    plan flips has_override=1 and skips field-walk entirely.
  * Extension range hooks captured (proto2 scaffolding for bd-3i).
  * Field-name cache as a Lua table referenced via luaL_ref, so
    encode/decode can do lua_rawgeti instead of re-interning C strings.

Cycle handling: compile_plan stashes the new plan userdata on
desc.c_plan BEFORE recursing into sub-message fields. Person.friends
→ Person resolves to the same userdata; the test asserts identity.

Build entry: 'just build-c' compiles runtime/pb/c/c_runtime.c into
runtime/pb/c_runtime.dylib (or .so on Linux) via the local Makefile.
Module-h location auto-detected the same way bench/c_accel/Makefile
does it. Tarantool's LuaJIT-on-5.1 means we use lua_objlen (not
lua_rawlen) and provide a local abs_idx helper since lua_absindex
isn't available in the 5.1 compat layer.

Smoke test at test/c_runtime_plan_test.lua exercises both codegen
modes (full + runtime). 26 assertions cover:
  - module surface + ABI version + KIND/WIRE constants
  - Address: 4 fields, names, kinds, wire types, tag bytes, optional
  - Person: 14 fields, scalars/enum/message/map/repeated/packed
  - Sub-plan resolution + Person.friends self-reference cycle break
  - Idempotent compile (second call returns cached plan)
  - Result.outcome oneof: 3 members, oneof_idx back-pointers
  - WKT Timestamp: has_override=true, field-walk skipped

Full suite: 771/771 with PB_ENABLE_C=1 (745 existing + 26 new),
745+26 skipped without (silent fallback verified).

Justfile fix: 'just build-c' / 'clean-c' used $(MAKE) which Just
doesn't expand — switched to plain 'make'.

Unblocks bd-y1n (encode scalars), bd-mz6 (decode scalars),
bd-awv (64-bit cdata), bd-rmf (WKT passthrough). bd-mq7 closed.
M .beads/interactions.jsonl => .beads/interactions.jsonl +1 -0
@@ 18,3 18,4 @@
{"id":"int-ba4ac242","kind":"field_change","created_at":"2026-05-19T04:23:56.245983Z","actor":"Eugene Blikh","issue_id":"tarantool-protobuf-47e","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Compatibility contract formalized in docs/specs/c_accel_compat.md. Spec covers: PB_ENABLE_C=1 single activation switch, silent fallback, full public surface preservation (pb.encode/decode/decode_lazy/parse/from_pb/json/text/grpc), generated module wrappers, 64-bit cdata invariant, WKT shapes, unknown fields, extensions, error compatibility, install paths (pure-Lua and C-enabled), ABI versioning, conformance via existing-suite re-run, explicit out-of-scope list. Lazy view stays Lua-only per spike rationale. Downstream tasks (mq7, ra6, 43t, c0i) reference this spec for compat boundaries."}}
{"id":"int-951d03f7","kind":"field_change","created_at":"2026-05-19T04:24:12.141983Z","actor":"Eugene Blikh","issue_id":"tarantool-protobuf-z7x","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"C-side strategy formalized in docs/specs/c_accel_strategy.md. Spec covers: pb_plan / pb_plan_field struct layout, field-name luaL_ref caching (spike-validated), 4 KB stack-backed pb_buf with malloc promotion, lua_createtable(0, n_fields) result pre-sizing, cached per-field stack indices for repeated/packed (spike Phase B: 2x speedup at 100 KB), 64-bit cdata via luaT_pushint64/checkint64, sub-buffer (chosen) vs backpatching (deferred) for sub-messages, two-pass packed encode, map iteration via lua_next, oneof grouping, unknown field passthrough, deferred micro-opts (manual varint inlining, SIMD scan), C99/-O2 build environment, deferred PB_C_TRACE/PB_C_CHECK_PARITY env knobs. ra6/mq7 references this spec for low-level decisions."}}
{"id":"int-7ce7c344","kind":"field_change","created_at":"2026-05-19T04:24:13.236096Z","actor":"Eugene Blikh","issue_id":"tarantool-protobuf-wky","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Build and packaging scaffolding landed: docs/specs/c_accel_build_packaging.md spec, runtime/pb/init.lua PB_ENABLE_C=1 opt-in pcall hook (exposed as pb.c_runtime field), Justfile build-c/clean-c recipes (stub erroring cleanly until bd-ra6 lands runtime/pb/c/), Justfile lua_cpath constant + LUA_CPATH on test/test-one, .gitignore for compiled .so/.dylib, sourcehut .builds/{pure-lua,c-enabled}.yml manifests (ubuntu/noble, parallel jobs via separate files since sourcehut has no matrix), .sourcehut/conformance.yml for manual conformance triggers. Rockspec changes deferred to first C-source landing (described in spec). All 745 tests pass with PB_ENABLE_C unset and PB_ENABLE_C=1 (silent fallback verified)."}}
{"id":"int-b6e91276","kind":"field_change","created_at":"2026-05-19T04:46:39.175032Z","actor":"Eugene Blikh","issue_id":"tarantool-protobuf-mq7","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Descriptor -> C plan compiler landed in runtime/pb/c/c_runtime.c. Compiles a finalized Lua descriptor into an opaque pb_plan userdata with: per-field records (field_number, wire_type, kind, repeated/packed/optional, pre-encoded tag bytes, sub_plan_idx, oneof_idx, enum_ref), oneof grouping (parallel array with member indices + field oneof_idx back-pointers), WKT override pointers (has_override + override_encode_ref/decode_ref skipping field-walk), extension range hooks (proto2 scaffolding), field-name luaL_ref cache, sub-plan table for cycle-safe recursion. Self-references (Person.friends -> Person) handled by stashing c_plan on desc BEFORE recursing, so resolve_sub_plan returns the same userdata. Idempotent: re-compiling returns cached plan. Build via 'just build-c'. Smoke test at test/c_runtime_plan_test.lua: 26 assertions across both codegen modes (full + runtime) verify module surface, scalar/enum/message/map/repeated/packed shapes, sub-plan resolution, self-reference cycle break, oneof grouping (Result.outcome), idempotency, WKT override detection (Timestamp). Full suite: 771/771 with PB_ENABLE_C=1, 745+26 skipped without (silent fallback verified). Unblocks 3b/3c/3l/3k (y1n, mz6, awv, rmf)."}}

M .beads/issues.jsonl => .beads/issues.jsonl +2 -2
@@ 4,7 4,7 @@
{"_type":"issue","id":"tarantool-protobuf-awv","title":"ra6 3l: 64-bit cdata fidelity","description":"int64/uint64/fixed64/sfixed64 must round-trip as LuaJIT cdata in both encode and decode — never narrowed to a Lua double. Same convention as msgpackffi, net.box, box.tuple, the built-in protobuf. Use luaT_pushuint64/luaT_pushint64 from Tarantool's module.h for the C side. Independent transverse concern — can land in parallel with 3b/3c since it's a per-kind fix rather than a new pipeline stage. Acceptance: a value \u003e 2^53 round-trips byte-equal and remains cdata after decode; luatest test/protobuf_test.lua int64-cdata cases pass with PB_ENABLE_C=1.","status":"open","priority":1,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:20:10Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T20:20:10Z","dependencies":[{"issue_id":"tarantool-protobuf-awv","depends_on_id":"tarantool-protobuf-mq7","type":"blocks","created_at":"2026-05-18T23:21:55Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-mz6","title":"ra6 3c: decode scalars","description":"C-side decode for scalar wire types. Pre-sized result table via lua_createtable(0, n_fields) using descriptor stats; per-field plan-driven dispatch on wire type and kind; cached luaL_ref-based lua_setfield. Wire→table output must equal mode=full Lua output for the same input bytes. Depends on 3a. Acceptance: Person decode round-trip works for the bytes produced by 3b; the decoded table is shape-identical to pure-Lua decode (verified by running test/interop tests with PB_ENABLE_C=1).","status":"open","priority":1,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:20:06Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T20:20:06Z","dependencies":[{"issue_id":"tarantool-protobuf-mz6","depends_on_id":"tarantool-protobuf-mq7","type":"blocks","created_at":"2026-05-18T23:21:54Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":6,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-y1n","title":"ra6 3b: encode scalars (varint, fixed32/64, double, bool, enum, sint)","description":"C-side encode for scalar wire types into the message's output buffer. Reads field values via cached luaL_ref + lua_rawgeti, dispatches on plan field kind, writes varint/fixed32/fixed64/double/bool/enum/sint into the 4KB stack-backed buffer with malloc-promotion on overflow. Wire-byte output must be byte-equal to mode=full Lua output. Depends on 3a (needs the plan). Acceptance: Person encode round-trip works for {name='x', age=42, balance=-7, user_id=0xDEADBEEFCAFEBABEULL, weight_kg=3.14} with byte-equal output to mode=full pure Lua.","status":"open","priority":1,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:20:03Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T20:20:03Z","dependencies":[{"issue_id":"tarantool-protobuf-y1n","depends_on_id":"tarantool-protobuf-mq7","type":"blocks","created_at":"2026-05-18T23:21:53Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":6,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-mq7","title":"ra6 3a: descriptor → C plan compiler","description":"Compile finalized Lua descriptors into a C-side plan userdata, stashed on desc.c_plan. The plan carries per-field records (field number, wire type, kind tag, presence offset, default index), pre-encoded tag bytes, luaL_ref slots for cached field names, sub-descriptor pointers, oneof grouping metadata (parallel array, matches desc.oneofs_list), extension range hooks, and WKT override pointers (desc.encode/desc.decode passthrough). Rebuilt at pb.finalize_message time when PB_ENABLE_C=1. Not serialized, not shared across processes. Foundation for all other ra6 sub-issues — no other 3* issue can start until this lands. Acceptance: pb.c_runtime.compile_plan(desc) returns a userdata; plan-\u003en_fields and plan-\u003efields[i].tag are readable from a C-level smoke test; plans for hello.Person and Address build without error.","status":"open","priority":1,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:19:58Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T20:19:58Z","dependencies":[{"issue_id":"tarantool-protobuf-mq7","depends_on_id":"tarantool-protobuf-47e","type":"blocks","created_at":"2026-05-18T23:21:52Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-mq7","depends_on_id":"tarantool-protobuf-pf6","type":"blocks","created_at":"2026-05-18T23:21:52Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-mq7","depends_on_id":"tarantool-protobuf-z7x","type":"blocks","created_at":"2026-05-18T23:21:53Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":3,"dependent_count":5,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-mq7","title":"ra6 3a: descriptor → C plan compiler","description":"Compile finalized Lua descriptors into a C-side plan userdata, stashed on desc.c_plan. The plan carries per-field records (field number, wire type, kind tag, presence offset, default index), pre-encoded tag bytes, luaL_ref slots for cached field names, sub-descriptor pointers, oneof grouping metadata (parallel array, matches desc.oneofs_list), extension range hooks, and WKT override pointers (desc.encode/desc.decode passthrough). Rebuilt at pb.finalize_message time when PB_ENABLE_C=1. Not serialized, not shared across processes. Foundation for all other ra6 sub-issues — no other 3* issue can start until this lands. Acceptance: pb.c_runtime.compile_plan(desc) returns a userdata; plan-\u003en_fields and plan-\u003efields[i].tag are readable from a C-level smoke test; plans for hello.Person and Address build without error.","status":"in_progress","priority":1,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:19:58Z","created_by":"Eugene Blikh","updated_at":"2026-05-19T04:26:53Z","started_at":"2026-05-19T04:26:53Z","dependencies":[{"issue_id":"tarantool-protobuf-mq7","depends_on_id":"tarantool-protobuf-47e","type":"blocks","created_at":"2026-05-18T23:21:52Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-mq7","depends_on_id":"tarantool-protobuf-pf6","type":"blocks","created_at":"2026-05-18T23:21:52Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-mq7","depends_on_id":"tarantool-protobuf-z7x","type":"blocks","created_at":"2026-05-18T23:21:53Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":3,"dependent_count":5,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-1eq","title":"C accel spike: generic one-call C codec variant (strategy 3)","description":"Sub-issue of tarantool-protobuf-04c. Implement strategy 3: a generic C codec that gets a descriptor + Lua table once per top-level encode/decode call and owns the inner loop. Internally dispatches per-field on descriptor kind (the work pf6 / ra6 ends up shipping in production). Measure with bench/c_accel/spike_bench.lua. Acceptance: numbers in bench/c_accel/README.md showing the gap to strategy 4 (hand-written, no dispatch).","notes":"Phase A measured strategy 4 ceiling at 3-10x over pure Lua. The 4 vs 3 gap is the key signal for pf6 architecture: \u003c20-40% gap means generic C runtime (ra6) alone is sufficient; \u003e40% gap means we need codegen-emitted C (c0i). For the spike, dispatch can be simple (switch on protoreflect.Kind); no need to design the compiled-plan format here.","status":"closed","priority":1,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T19:28:08Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T19:44:07Z","closed_at":"2026-05-18T19:44:07Z","close_reason":"Measured as part of the 04c spike; numbers and architecture conclusion captured in 04c close, bench/c_accel/README.md, and bd memory c-accel-spike-04c-final-2026-05-18.","dependencies":[{"issue_id":"tarantool-protobuf-1eq","depends_on_id":"tarantool-protobuf-04c","type":"blocks","created_at":"2026-05-18T22:28:15Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-80t","title":"C accel spike: per-primitive FFI variant (strategy 2)","description":"Sub-issue of tarantool-protobuf-04c. Implement strategy 2 of the C-acceleration benchmark: replace wire.lua's encode_varint / decode_varint / encode_string / decode_string / fixed32/64 / double with ffi.C.\u003cfn\u003e calls into a small C shim. The Lua dispatch loop stays; only the primitives cross. Measure with bench/c_accel/spike_bench.lua across the same 5 sizes. Acceptance: numbers added to bench/c_accel/README.md results table.","notes":"Phase A landed strategies 1 + 4. Strategy 4 (hand-written Person C codec) sets the upper bound at 3-10x over pure Lua. Strategy 2 must clear ~3x at every size to be competitive with 4; otherwise FFI per-primitive boundary cost is the wrong place to spend complexity. See bench/c_accel/README.md for full Phase A results.","status":"closed","priority":1,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T19:28:02Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T19:44:07Z","closed_at":"2026-05-18T19:44:07Z","close_reason":"Measured as part of the 04c spike; numbers and architecture conclusion captured in 04c close, bench/c_accel/README.md, and bd memory c-accel-spike-04c-final-2026-05-18.","dependencies":[{"issue_id":"tarantool-protobuf-80t","depends_on_id":"tarantool-protobuf-04c","type":"blocks","created_at":"2026-05-18T22:28:15Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-04c","title":"C accel: benchmark spike for Lua C module vs FFI boundaries","description":"Build a small benchmark spike before committing to a full backend. Compare: pure Lua current path, per-primitive FFI calls, one Lua C module call per top-level encode/decode, and a hand-written C codec for hello.Person. Measure 10B/100B/1KB/10KB/100KB Person and proto2 BenchPayload. Acceptance: numbers show which boundary wins and quantify C-call overhead for tiny messages where current Lua decode already beats starwing.","status":"closed","priority":1,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-17T16:32:31Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T19:43:59Z","started_at":"2026-05-18T19:21:27Z","closed_at":"2026-05-18T19:43:59Z","close_reason":"Spike complete across all 4 boundaries (Lua / S2 FFI prims / S3 generic C / S4 hand C) at 10B-100KB. Bench: bench/c_accel/. Decisive results. S3 ≈ S4 within 15% (S3 often faster on encode at scale). S2 LOSES vs pure-Lua at every size 1KB+ on encode (0.60-0.99x) and at every size on decode (0.29-0.39x). FFI cost decomposition (bench/c_accel/ffi_probe.lua, memory luajit-ffi-boundary-cost-2026-05-18): bare FFI = 33ns; pointer-return FFI = 73ns; ffi.cast on string = 156ns; libc memcmp = 60ns; pure-Lua varint decode = 75ns. Per-primitive boundary cost equals pure-Lua dispatch cost — only message-level C crossing wins. ARCHITECTURE for pf6: ship ra6 (generic C runtime, descriptor-walking, one C call per message); drop c0i (≤15% headroom over ra6); drop per-primitive FFI. Required ra6 impl pattern: cache per-field stack indices for repeated/packed arrays during decode_message (naive lazy-getfield 2x slower at 100KB). Full details in bench/c_accel/README.md and bd memory c-accel-spike-04c-final-2026-05-18 + luajit-ffi-boundary-cost-2026-05-18.","dependency_count":0,"dependent_count":3,"comment_count":0}


@@ 32,7 32,7 @@
{"_type":"issue","id":"tarantool-protobuf-43t","title":"C accel: conformance and parity gate for C paths","description":"Extend tests/benches so C runtime and generated-C modes must pass the same luatest, interop, JSON/text, proto2, lazy passthrough where applicable, and Google conformance suites as Lua modes. Add bench output columns for lua-full, lua-runtime, c-runtime, c-generated, starwing. C acceleration must remain byte-equal with current encoders and preserve unknown fields/extensions/groups.","notes":"Architecture finalized in docs/c-accel.md. Parity strategy: REUSE existing test suites — no separate Lua-vs-C diff harness. (1) just test (luatest, 639 tests already parameterized over full/runtime modes) runs once with PB_ENABLE_C unset and once with PB_ENABLE_C=1. Both must pass. (2) just conformance (Google proto3 + proto2) runs both modes. (3) Interop fixtures (test/interop/fixtures/*.bin) cover both modes via the existing parity.full_vs_runtime tests; extend the matrix to also cover c-runtime. Logic: every assert is against a reference output (golden bytes, txtpb, conformance result). If Lua passes and C passes, both equal the reference, so Lua == C by transitivity. No new test infrastructure required. bench/bench.lua gains a c-runtime column.","status":"open","priority":2,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-17T16:32:53Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T20:19:36Z","dependencies":[{"issue_id":"tarantool-protobuf-43t","depends_on_id":"tarantool-protobuf-c0i","type":"blocks","created_at":"2026-05-17T19:33:44Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-43t","depends_on_id":"tarantool-protobuf-ra6","type":"blocks","created_at":"2026-05-17T19:33:44Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-43t","depends_on_id":"tarantool-protobuf-wky","type":"blocks","created_at":"2026-05-17T19:33:44Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-z7x","title":"C accel: C table and string handling strategy","description":"Design and implement the low-level C helpers for Lua table access and string/buffer handling. Cache field-name strings/registry refs in descriptor plans, pre-size result tables where possible, build output strings with luaL_Buffer or exact-size allocation, avoid per-field Lua stack churn, and handle 64-bit cdata consistently with the current Lua API.","notes":"Architecture finalized in docs/c-accel.md. Strategy decisions validated by bench/c_accel/ spike: (1) Field-name strings cached as luaL_ref slots at plan-compile time; each decode does lua_rawgeti from the cached ref instead of re-interning. (2) Output buffer: 4KB stack-backed cap with malloc promotion on overflow (pattern in bench/c_accel/person_codec.c — validates against tiny-msg overhead and big-msg correctness). (3) Result tables pre-sized via lua_createtable(0, n_fields) from descriptor stats. (4) Per-field stack indices cached for repeated/packed array fields for the duration of decode_message — naive lazy-getfield was 2x slower at 100KB (spike Phase B). (5) 64-bit cdata via luaT_pushuint64/luaT_pushint64 (Tarantool extensions in module.h). (6) Sub-messages use separate buf with stack-allocated header; backpatching avoided (the bench showed sub-buf approach is fast enough).","status":"closed","priority":2,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-17T16:32:53Z","created_by":"Eugene Blikh","updated_at":"2026-05-19T04:24:12Z","started_at":"2026-05-19T04:10:45Z","closed_at":"2026-05-19T04:24:12Z","close_reason":"C-side strategy formalized in docs/specs/c_accel_strategy.md. Spec covers: pb_plan / pb_plan_field struct layout, field-name luaL_ref caching (spike-validated), 4 KB stack-backed pb_buf with malloc promotion, lua_createtable(0, n_fields) result pre-sizing, cached per-field stack indices for repeated/packed (spike Phase B: 2x speedup at 100 KB), 64-bit cdata via luaT_pushint64/checkint64, sub-buffer (chosen) vs backpatching (deferred) for sub-messages, two-pass packed encode, map iteration via lua_next, oneof grouping, unknown field passthrough, deferred micro-opts (manual varint inlining, SIMD scan), C99/-O2 build environment, deferred PB_C_TRACE/PB_C_CHECK_PARITY env knobs. ra6/mq7 references this spec for low-level decisions.","dependency_count":0,"dependent_count":3,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-47e","title":"C accel: preserve pure-Lua fallback and public API compatibility","description":"Define compatibility boundaries for C acceleration. Existing generated Lua modules and runtime pb APIs must keep working without a compiler or C module. New generated-C mode should be opt-in initially. Errors, 64-bit cdata behavior, WKT shapes, unknown fields, extension representation, lazy decode API, and text/JSON integration must remain compatible.","notes":"Architecture finalized in docs/c-accel.md. Concrete contract: (1) PB_ENABLE_C=1 env var is the only activation switch, default off. (2) require('pb') returns the same Lua surface in both modes — no API change. (3) 64-bit ints stay LuaJIT cdata (int64_t/uint64_t) in both modes. (4) WKT shapes unchanged. (5) Unknown fields round-trip identically. (6) Errors land as the same Lua error types. (7) Pure-Lua install must work without a C compiler — rockspec opt-builds the C module, install never fails on a host without cc. (8) When C module fails to load or PB_ENABLE_C is unset, runtime/pb/init.lua transparently uses the pure-Lua path.","status":"closed","priority":2,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-17T16:32:52Z","created_by":"Eugene Blikh","updated_at":"2026-05-19T04:23:56Z","started_at":"2026-05-19T04:10:44Z","closed_at":"2026-05-19T04:23:56Z","close_reason":"Compatibility contract formalized in docs/specs/c_accel_compat.md. Spec covers: PB_ENABLE_C=1 single activation switch, silent fallback, full public surface preservation (pb.encode/decode/decode_lazy/parse/from_pb/json/text/grpc), generated module wrappers, 64-bit cdata invariant, WKT shapes, unknown fields, extensions, error compatibility, install paths (pure-Lua and C-enabled), ABI versioning, conformance via existing-suite re-run, explicit out-of-scope list. Lazy view stays Lua-only per spike rationale. Downstream tasks (mq7, ra6, 43t, c0i) reference this spec for compat boundaries.","dependency_count":0,"dependent_count":3,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-wky","title":"C accel: build and packaging support for C modules","description":"Add build-system and packaging support for C acceleration. Needs Justfile targets, rockspec support for compiled modules, platform naming, local dev build, CI matrix integration, and fallback when the C module is absent. Generated C backend must not make pure-Lua install impossible unless explicitly selected.","notes":"Architecture finalized in docs/c-accel.md. Concrete deliverables: (1) Justfile recipe 'just build-c' that builds the C runtime module (mirror bench/c_accel/Makefile auto-detection of TT_INC). (2) Rockspec optionally builds the C module — install on host without cc must succeed and produce a pure-Lua install. (3) runtime/pb/init.lua does pcall(require, 'pb.c_runtime') only when os.getenv('PB_ENABLE_C') == '1'. (4) Sourcehut CI build manifest gains one extra job that runs the full test+conformance suite with PB_ENABLE_C=1 set — single .build.yml, both modes covered on every push to master. (5) CI also runs one variant without the C module compiled to confirm pure-Lua install works.","status":"in_progress","priority":2,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-17T16:32:52Z","created_by":"Eugene Blikh","updated_at":"2026-05-19T04:10:45Z","started_at":"2026-05-19T04:10:45Z","dependencies":[{"issue_id":"tarantool-protobuf-wky","depends_on_id":"tarantool-protobuf-pf6","type":"blocks","created_at":"2026-05-17T19:33:11Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-wky","title":"C accel: build and packaging support for C modules","description":"Add build-system and packaging support for C acceleration. Needs Justfile targets, rockspec support for compiled modules, platform naming, local dev build, CI matrix integration, and fallback when the C module is absent. Generated C backend must not make pure-Lua install impossible unless explicitly selected.","notes":"Architecture finalized in docs/c-accel.md. Concrete deliverables: (1) Justfile recipe 'just build-c' that builds the C runtime module (mirror bench/c_accel/Makefile auto-detection of TT_INC). (2) Rockspec optionally builds the C module — install on host without cc must succeed and produce a pure-Lua install. (3) runtime/pb/init.lua does pcall(require, 'pb.c_runtime') only when os.getenv('PB_ENABLE_C') == '1'. (4) Sourcehut CI build manifest gains one extra job that runs the full test+conformance suite with PB_ENABLE_C=1 set — single .build.yml, both modes covered on every push to master. (5) CI also runs one variant without the C module compiled to confirm pure-Lua install works.","status":"closed","priority":2,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-17T16:32:52Z","created_by":"Eugene Blikh","updated_at":"2026-05-19T04:24:13Z","started_at":"2026-05-19T04:10:45Z","closed_at":"2026-05-19T04:24:13Z","close_reason":"Build and packaging scaffolding landed: docs/specs/c_accel_build_packaging.md spec, runtime/pb/init.lua PB_ENABLE_C=1 opt-in pcall hook (exposed as pb.c_runtime field), Justfile build-c/clean-c recipes (stub erroring cleanly until bd-ra6 lands runtime/pb/c/), Justfile lua_cpath constant + LUA_CPATH on test/test-one, .gitignore for compiled .so/.dylib, sourcehut .builds/{pure-lua,c-enabled}.yml manifests (ubuntu/noble, parallel jobs via separate files since sourcehut has no matrix), .sourcehut/conformance.yml for manual conformance triggers. Rockspec changes deferred to first C-source landing (described in spec). All 745 tests pass with PB_ENABLE_C unset and PB_ENABLE_C=1 (silent fallback verified).","dependencies":[{"issue_id":"tarantool-protobuf-wky","depends_on_id":"tarantool-protobuf-pf6","type":"blocks","created_at":"2026-05-17T19:33:11Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-801","title":"Release: tagged releases + rockspec + Go plugin binaries","description":"Once the CI pipeline is green, ship tagged releases. Rockspec for the Lua runtime (publishable via tt rocks). Pre-built protoc-gen-tarantool binaries for darwin/arm64, darwin/amd64, linux/amd64, linux/arm64. Sourcecraft has 'sc release' tooling (see sc-release skill) for the release workflow itself.","status":"open","priority":2,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-17T15:47:16Z","created_by":"Eugene Blikh","updated_at":"2026-05-17T15:47:16Z","labels":["release"],"dependencies":[{"issue_id":"tarantool-protobuf-801","depends_on_id":"tarantool-protobuf-1eu","type":"blocks","created_at":"2026-05-17T18:47:20Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-1eu","title":"Release: Sourcecraft.dev project + CI pipeline","description":"Set up the canonical sourcecraft.dev project for the repo and a CI pipeline. Matrix: Tarantool 2.11 (CE+EE) and 3.x (CE+EE), Linux + macOS. Targets to run: just gen, just test, just bench-compare (alloc regression gate), just conformance (gated on the cached Docker image — see related CI wire-up bead). The repo lives under ~/data/home which by convention publishes to sourcecraft.dev (not github).","status":"open","priority":2,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-17T15:47:15Z","created_by":"Eugene Blikh","updated_at":"2026-05-17T15:47:15Z","labels":["ci","release"],"dependencies":[{"issue_id":"tarantool-protobuf-1eu","depends_on_id":"tarantool-protobuf-7lf","type":"blocks","created_at":"2026-05-17T18:47:20Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-7lf","title":"CI: wire up conformance suite with cached Docker image","description":"The Docker image build (docker/conformance.Dockerfile) is the long pole at ~10-15 min on a clean cache. A registry push from a scheduled job would let CI runs reuse a warm image. Today the conformance suite runs locally via 'just conformance' but isn't gated on pushes. Goal: every push to master runs the binary+JSON and text-format suites; PRs run the same. Pre-requisite for M8 sourcecraft setup (this defines what the CI pipeline runs).","status":"open","priority":2,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-17T15:47:15Z","created_by":"Eugene Blikh","updated_at":"2026-05-17T15:47:15Z","labels":["ci","conformance"],"dependency_count":0,"dependent_count":1,"comment_count":0}

M Justfile => Justfile +2 -2
@@ 74,12 74,12 @@ build-c:
        echo "  See docs/specs/c_accel_build_packaging.md."; \
        exit 1; \
    fi
    $(MAKE) -C runtime/pb/c
    make -C runtime/pb/c

# Remove built C-runtime artifacts.
clean-c:
    rm -f runtime/pb/c_runtime.so runtime/pb/c_runtime.dylib
    @if [ -d runtime/pb/c ]; then $(MAKE) -C runtime/pb/c clean; fi
    @if [ -d runtime/pb/c ]; then make -C runtime/pb/c clean; fi

# ---------------------------------------------------------------------------
# Codegen

A runtime/pb/c/Makefile => runtime/pb/c/Makefile +55 -0
@@ 0,0 1,55 @@
# Makefile for pb.c_runtime — the optional C-acceleration runtime.
#
# Output sits at ../c_runtime.{so,dylib} so that
#   require('pb.c_runtime')
# resolves it via the LUA_CPATH the Justfile sets for `just test`
# (see Justfile lua_cpath: "./runtime/?.so;./runtime/?.dylib;...").
#
# Built only when PB_ENABLE_C=1 is part of the user's workflow.
# Pure-Lua install ignores this directory entirely.

# Locate <module.h>. Order: env override, brew prefix, common system dirs.
# Mirrors bench/c_accel/Makefile.
TT_INC ?= $(shell \
	if [ -n "$$TARANTOOL_INCLUDE" ] && [ -f "$$TARANTOOL_INCLUDE/module.h" ]; then \
		echo "$$TARANTOOL_INCLUDE"; exit 0; \
	fi; \
	for d in \
		$$(brew --prefix tarantool 2>/dev/null)/include/tarantool \
		/opt/homebrew/include/tarantool \
		/usr/local/include/tarantool \
		/usr/include/tarantool; do \
		if [ -f "$$d/module.h" ]; then echo "$$d"; exit 0; fi; \
	done)

ifeq ($(TT_INC),)
$(error Cannot find <module.h>; set TARANTOOL_INCLUDE=/path/to/include/tarantool or install tarantool-dev)
endif

UNAME_S := $(shell uname -s)

CFLAGS  := -O2 -fPIC -Wall -Wextra -std=c99 -I$(TT_INC)
ifeq ($(UNAME_S),Darwin)
	LIB_EXT := dylib
	LDFLAGS := -bundle -undefined dynamic_lookup
else
	LIB_EXT := so
	LDFLAGS := -shared
endif

SRCS    := c_runtime.c
OBJS    := $(SRCS:.c=.o)
OUTPUT  := ../c_runtime.$(LIB_EXT)

all: $(OUTPUT)

$(OUTPUT): $(OBJS)
	$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(OBJS)

%.o: %.c
	$(CC) $(CFLAGS) -c -o $@ $<

clean:
	rm -f *.o $(OUTPUT)

.PHONY: all clean

A runtime/pb/c/c_runtime.c => runtime/pb/c/c_runtime.c +840 -0
@@ 0,0 1,840 @@
/*
 * c_runtime.c -- C-acceleration runtime for tarantool-protobuf.
 *
 * Phase 1 (bd-mq7): descriptor -> plan compiler. Walks a finalized
 * Lua descriptor table and produces an opaque `pb_plan` userdata.
 * Encode/decode entry points arrive with bd-3b/3c/etc.; this file
 * only carries the plan compilation surface and enough introspection
 * to make a smoke test possible.
 *
 * Conventions follow docs/specs/c_accel_strategy.md.
 */

#include <module.h>
#include <lauxlib.h>

#include <stdint.h>
#include <stdlib.h>
#include <string.h>

#define PB_PLAN_MT      "pb.plan"
#define PB_ABI_VERSION  "1"

/* ---------------------------------------------------------------- *
 *  Kind / wire-type taxonomy.                                       *
 *                                                                  *
 *  Mirrors runtime/pb/wire.lua's TYPE_INFO. The numbering is        *
 *  internal — only the C runtime needs to agree with itself.       *
 * ---------------------------------------------------------------- */

enum {
	PB_KIND_NONE = 0,
	PB_KIND_INT32,
	PB_KIND_INT64,
	PB_KIND_UINT32,
	PB_KIND_UINT64,
	PB_KIND_SINT32,
	PB_KIND_SINT64,
	PB_KIND_FIXED32,
	PB_KIND_FIXED64,
	PB_KIND_SFIXED32,
	PB_KIND_SFIXED64,
	PB_KIND_FLOAT,
	PB_KIND_DOUBLE,
	PB_KIND_BOOL,
	PB_KIND_STRING,
	PB_KIND_BYTES,
	PB_KIND_ENUM,
	PB_KIND_MESSAGE,
	PB_KIND_MAP,
};

/* Wire types per proto3 spec. */
enum {
	PB_WIRE_VARINT = 0,
	PB_WIRE_I64    = 1,
	PB_WIRE_LEN    = 2,
	PB_WIRE_I32    = 5,
};

struct kind_info {
	const char *proto_type;
	uint8_t kind;
	uint8_t wire_type;
};

/* Lookup table keyed by proto3 field type string. Linear scan is fine —
 * a finalized descriptor calls this once per field at plan-compile time,
 * not per encode/decode. */
static const struct kind_info kind_table[] = {
	{"int32",    PB_KIND_INT32,    PB_WIRE_VARINT},
	{"int64",    PB_KIND_INT64,    PB_WIRE_VARINT},
	{"uint32",   PB_KIND_UINT32,   PB_WIRE_VARINT},
	{"uint64",   PB_KIND_UINT64,   PB_WIRE_VARINT},
	{"sint32",   PB_KIND_SINT32,   PB_WIRE_VARINT},
	{"sint64",   PB_KIND_SINT64,   PB_WIRE_VARINT},
	{"bool",     PB_KIND_BOOL,     PB_WIRE_VARINT},
	{"fixed32",  PB_KIND_FIXED32,  PB_WIRE_I32},
	{"sfixed32", PB_KIND_SFIXED32, PB_WIRE_I32},
	{"float",    PB_KIND_FLOAT,    PB_WIRE_I32},
	{"fixed64",  PB_KIND_FIXED64,  PB_WIRE_I64},
	{"sfixed64", PB_KIND_SFIXED64, PB_WIRE_I64},
	{"double",   PB_KIND_DOUBLE,   PB_WIRE_I64},
	{"string",   PB_KIND_STRING,   PB_WIRE_LEN},
	{"bytes",    PB_KIND_BYTES,    PB_WIRE_LEN},
};

static const struct kind_info *
lookup_kind(const char *proto_type)
{
	if (proto_type == NULL)
		return NULL;
	for (size_t i = 0; i < sizeof(kind_table) / sizeof(kind_table[0]); i++) {
		if (strcmp(kind_table[i].proto_type, proto_type) == 0)
			return &kind_table[i];
	}
	return NULL;
}

/* ---------------------------------------------------------------- *
 *  Plan struct layout.                                              *
 *                                                                  *
 *  Spec: docs/specs/c_accel_strategy.md § The plan userdata.        *
 *  Simplifications from the spec for mq7:                          *
 *    - oneofs[] populated but member dispatch lives in 3i          *
 *    - extension_range_* populated but dispatch lives in 3i        *
 *    - sub_plan_idx points into `sub_plans_ref` table (1-based)    *
 *    - Field name strings live in a Lua table keyed by 1..n;       *
 *      lookup via `lua_rawgeti(L, names, i+1)` per spec.           *
 * ---------------------------------------------------------------- */

typedef struct pb_plan_field {
	uint32_t field_number;
	uint8_t  wire_type;
	uint8_t  kind;
	uint8_t  packed;
	uint8_t  repeated;
	uint8_t  optional;
	uint8_t  tag_len;
	uint8_t  tag_bytes[5];
	int      sub_plan_idx;       /* 1-based into sub_plans table; 0 if none */
	uint8_t  map_key_kind;
	uint8_t  map_value_kind;
	int      map_value_sub_plan_idx; /* 1-based; 0 if value is scalar */
	int      oneof_idx;          /* 0-based into plan->oneofs; -1 if none */
	int      enum_ref;           /* LUA_REGISTRYINDEX ref for enum desc; LUA_NOREF if none */
} pb_plan_field;

typedef struct pb_plan_oneof {
	char *name;                  /* malloc'd */
	int   n_members;
	int  *member_indices;        /* indices into plan->fields */
} pb_plan_oneof;

typedef struct pb_plan {
	char *name;                  /* malloc'd descriptor name */
	int   n_fields;
	pb_plan_field *fields;
	int   n_oneofs;
	pb_plan_oneof *oneofs;
	int   extension_range_start;
	int   extension_range_end;
	uint8_t has_override;
	int   override_encode_ref;   /* LUA_NOREF if absent */
	int   override_decode_ref;
	int   field_names_ref;       /* table { [1]=name1, ... } */
	int   sub_plans_ref;         /* table { [1]=plan_userdata, ... } */
} pb_plan;

/* ---------------------------------------------------------------- *
 *  Tag encoding.                                                    *
 *                                                                  *
 *  Pre-encodes the (field_number << 3) | wire_type varint so the    *
 *  hot encode path emits a fixed memcpy instead of recomputing.    *
 *  Up to 5 bytes for any legal field number (2^29 - 1 max).        *
 * ---------------------------------------------------------------- */

static void
encode_tag(uint32_t field_number, uint8_t wire_type,
           uint8_t *out, uint8_t *out_len)
{
	uint64_t v = ((uint64_t)field_number << 3) | wire_type;
	uint8_t i = 0;
	while (v >= 0x80) {
		out[i++] = (uint8_t)(v | 0x80);
		v >>= 7;
	}
	out[i++] = (uint8_t)v;
	*out_len = i;
}

/* ---------------------------------------------------------------- *
 *  Plan lifecycle: alloc / free.                                    *
 * ---------------------------------------------------------------- */

static void
plan_free(lua_State *L, pb_plan *p)
{
	if (p->name != NULL)
		free(p->name);
	if (p->fields != NULL) {
		for (int i = 0; i < p->n_fields; i++) {
			if (p->fields[i].enum_ref != LUA_NOREF)
				luaL_unref(L, LUA_REGISTRYINDEX, p->fields[i].enum_ref);
		}
		free(p->fields);
	}
	if (p->oneofs != NULL) {
		for (int i = 0; i < p->n_oneofs; i++) {
			free(p->oneofs[i].name);
			free(p->oneofs[i].member_indices);
		}
		free(p->oneofs);
	}
	if (p->override_encode_ref != LUA_NOREF)
		luaL_unref(L, LUA_REGISTRYINDEX, p->override_encode_ref);
	if (p->override_decode_ref != LUA_NOREF)
		luaL_unref(L, LUA_REGISTRYINDEX, p->override_decode_ref);
	if (p->field_names_ref != LUA_NOREF)
		luaL_unref(L, LUA_REGISTRYINDEX, p->field_names_ref);
	if (p->sub_plans_ref != LUA_NOREF)
		luaL_unref(L, LUA_REGISTRYINDEX, p->sub_plans_ref);

	memset(p, 0, sizeof(*p));
	p->override_encode_ref = LUA_NOREF;
	p->override_decode_ref = LUA_NOREF;
	p->field_names_ref = LUA_NOREF;
	p->sub_plans_ref = LUA_NOREF;
}

static int
plan_gc(lua_State *L)
{
	pb_plan *p = (pb_plan *)luaL_checkudata(L, 1, PB_PLAN_MT);
	plan_free(L, p);
	return 0;
}

static int
plan_tostring(lua_State *L)
{
	pb_plan *p = (pb_plan *)luaL_checkudata(L, 1, PB_PLAN_MT);
	lua_pushfstring(L, "pb.plan: %s (n_fields=%d)",
	                p->name != NULL ? p->name : "(unnamed)",
	                p->n_fields);
	return 1;
}

/* ---------------------------------------------------------------- *
 *  Field compilation.                                               *
 *                                                                  *
 *  Reads one descriptor-field table (at the top of the stack) and  *
 *  fills the corresponding pb_plan_field. Recurses into sub-message *
 *  plans by calling compile_plan_impl.                              *
 *                                                                  *
 *  Stack expectations on entry:                                     *
 *    -1: field-descriptor table (e.g. {name="foo", id=1, kind=...})*
 *    sub_plans_stack_idx: the sub-plans table being filled         *
 *                                                                  *
 *  Stack on exit: same (we pop everything we push).                *
 * ---------------------------------------------------------------- */

static int compile_plan_impl(lua_State *L, int desc_idx);

/* Append a sub-plan userdata (at -1) to the sub-plans table at
 * sub_plans_idx; return its 1-based index. Pops the userdata. */
static int
push_sub_plan(lua_State *L, int sub_plans_idx)
{
	int n = (int)lua_objlen(L, sub_plans_idx);
	lua_rawseti(L, sub_plans_idx, n + 1);
	return n + 1;
}

/* Resolve a sub-message descriptor (at stack idx `sub_desc_idx`) into a
 * 1-based index into the sub-plans table. Stack-neutral: pushes/pops the
 * intermediate desc copy and plan userdata internally. */
static int
resolve_sub_plan(lua_State *L, int sub_desc_idx, int sub_plans_idx)
{
	int saved_top = lua_gettop(L);
	/* compile_plan_impl is idempotent — if desc.c_plan exists it just
	 * leaves the cached userdata on top. */
	lua_pushvalue(L, sub_desc_idx);
	int dup_idx = lua_gettop(L);
	compile_plan_impl(L, dup_idx);
	if (!lua_isuserdata(L, -1))
		luaL_error(L, "compile_plan_impl did not return a userdata");
	int idx = push_sub_plan(L, sub_plans_idx); /* pops plan userdata */
	lua_settop(L, saved_top);                  /* drop sub_desc copy */
	return idx;
}

/* Compile one field. `f_desc_idx` is the absolute stack index of the
 * field descriptor table. `field` points at the pb_plan_field slot to
 * fill. `field_names_idx`, `sub_plans_idx` are absolute indices of the
 * field-names and sub-plans tables being populated. `field_idx_1based`
 * is the 1-based position used for the field-names lookup table. */
static void
compile_field(lua_State *L, int f_desc_idx, pb_plan_field *field,
              int field_names_idx, int sub_plans_idx, int field_idx_1based)
{
	memset(field, 0, sizeof(*field));
	field->enum_ref = LUA_NOREF;
	field->oneof_idx = -1;

	/* field number */
	lua_getfield(L, f_desc_idx, "id");
	if (!lua_isnumber(L, -1))
		luaL_error(L, "field descriptor missing 'id'");
	field->field_number = (uint32_t)lua_tointeger(L, -1);
	lua_pop(L, 1);

	/* name (interned in field_names_ref under field_idx_1based) */
	lua_getfield(L, f_desc_idx, "name");
	if (!lua_isstring(L, -1))
		luaL_error(L, "field descriptor missing 'name'");
	lua_pushvalue(L, -1);                       /* dup */
	lua_rawseti(L, field_names_idx, field_idx_1based);
	lua_pop(L, 1);

	/* repeated / packed / optional */
	lua_getfield(L, f_desc_idx, "repeated");
	field->repeated = lua_toboolean(L, -1) ? 1 : 0;
	lua_pop(L, 1);
	lua_getfield(L, f_desc_idx, "packed");
	field->packed = lua_toboolean(L, -1) ? 1 : 0;
	lua_pop(L, 1);
	lua_getfield(L, f_desc_idx, "optional");
	field->optional = lua_toboolean(L, -1) ? 1 : 0;
	lua_pop(L, 1);

	/* kind dispatch on desc.kind */
	lua_getfield(L, f_desc_idx, "kind");
	const char *kind_str = lua_tostring(L, -1);
	if (kind_str == NULL)
		luaL_error(L, "field descriptor missing 'kind'");

	uint8_t element_wire_type;

	if (strcmp(kind_str, "scalar") == 0) {
		lua_pop(L, 1); /* pop kind */
		lua_getfield(L, f_desc_idx, "proto_type");
		const char *pt = lua_tostring(L, -1);
		const struct kind_info *ki = lookup_kind(pt);
		if (ki == NULL)
			luaL_error(L, "unknown scalar proto_type: %s",
			           pt != NULL ? pt : "(nil)");
		field->kind = ki->kind;
		element_wire_type = ki->wire_type;
		lua_pop(L, 1);
	} else if (strcmp(kind_str, "enum") == 0) {
		lua_pop(L, 1);
		field->kind = PB_KIND_ENUM;
		element_wire_type = PB_WIRE_VARINT;
		lua_getfield(L, f_desc_idx, "enum");
		if (lua_istable(L, -1)) {
			field->enum_ref = luaL_ref(L, LUA_REGISTRYINDEX);
		} else {
			lua_pop(L, 1);
		}
	} else if (strcmp(kind_str, "message") == 0) {
		lua_pop(L, 1);
		field->kind = PB_KIND_MESSAGE;
		element_wire_type = PB_WIRE_LEN;
		lua_getfield(L, f_desc_idx, "message");
		if (!lua_istable(L, -1))
			luaL_error(L, "message field '%s' missing 'message' descriptor",
			           "?");
		int sub_desc = lua_gettop(L);
		field->sub_plan_idx = resolve_sub_plan(L, sub_desc, sub_plans_idx);
		lua_pop(L, 1); /* sub-desc table */
	} else if (strcmp(kind_str, "map") == 0) {
		lua_pop(L, 1);
		field->kind = PB_KIND_MAP;
		element_wire_type = PB_WIRE_LEN;

		/* key */
		lua_getfield(L, f_desc_idx, "key");
		if (!lua_istable(L, -1))
			luaL_error(L, "map field missing 'key' descriptor");
		lua_getfield(L, -1, "proto_type");
		const struct kind_info *ki = lookup_kind(lua_tostring(L, -1));
		if (ki == NULL)
			luaL_error(L, "map key has unknown proto_type");
		field->map_key_kind = ki->kind;
		lua_pop(L, 2); /* proto_type + key */

		/* value */
		lua_getfield(L, f_desc_idx, "value");
		if (!lua_istable(L, -1))
			luaL_error(L, "map field missing 'value' descriptor");
		lua_getfield(L, -1, "kind");
		const char *vk = lua_tostring(L, -1);
		lua_pop(L, 1);
		if (vk != NULL && strcmp(vk, "scalar") == 0) {
			lua_getfield(L, -1, "proto_type");
			const struct kind_info *vki = lookup_kind(lua_tostring(L, -1));
			if (vki == NULL)
				luaL_error(L, "map value has unknown scalar proto_type");
			field->map_value_kind = vki->kind;
			lua_pop(L, 1);
		} else if (vk != NULL && strcmp(vk, "message") == 0) {
			field->map_value_kind = PB_KIND_MESSAGE;
			lua_getfield(L, -1, "message");
			if (lua_istable(L, -1)) {
				int sub_desc = lua_gettop(L);
				field->map_value_sub_plan_idx =
					resolve_sub_plan(L, sub_desc, sub_plans_idx);
			}
			lua_pop(L, 1);
		} else if (vk != NULL && strcmp(vk, "enum") == 0) {
			field->map_value_kind = PB_KIND_ENUM;
		} else {
			luaL_error(L, "map value has unknown kind: %s",
			           vk != NULL ? vk : "(nil)");
		}
		lua_pop(L, 1); /* value table */
	} else {
		luaL_error(L, "unknown field kind: %s", kind_str);
		return; /* unreachable */
	}

	/* Wire type: repeated+packed → LEN regardless of element type;
	 * repeated unpacked → element type per tag; singular → element. */
	if (field->repeated && field->packed) {
		field->wire_type = PB_WIRE_LEN;
	} else {
		field->wire_type = element_wire_type;
	}

	encode_tag(field->field_number, field->wire_type,
	           field->tag_bytes, &field->tag_len);
}

/* ---------------------------------------------------------------- *
 *  Oneof compilation.                                               *
 *                                                                  *
 *  desc.oneofs is the hash table {[name]=members}. We walk it and  *
 *  build pb_plan_oneof[]. For each member field, set its oneof_idx *
 *  to point back at the plan's oneof entry.                        *
 * ---------------------------------------------------------------- */

static int
find_field_by_name(pb_plan *p, lua_State *L, int field_names_idx,
                   const char *name)
{
	for (int i = 0; i < p->n_fields; i++) {
		lua_rawgeti(L, field_names_idx, i + 1);
		const char *fn = lua_tostring(L, -1);
		int match = (fn != NULL && strcmp(fn, name) == 0);
		lua_pop(L, 1);
		if (match) return i;
	}
	return -1;
}

static void
compile_oneofs(lua_State *L, pb_plan *p, int desc_idx, int field_names_idx)
{
	lua_getfield(L, desc_idx, "oneofs");
	if (!lua_istable(L, -1)) {
		lua_pop(L, 1);
		return;
	}

	/* First pass: count. */
	int count = 0;
	lua_pushnil(L);
	while (lua_next(L, -2) != 0) {
		count++;
		lua_pop(L, 1);
	}
	if (count == 0) {
		lua_pop(L, 1);
		return;
	}

	p->oneofs = (pb_plan_oneof *)calloc(count, sizeof(pb_plan_oneof));
	p->n_oneofs = count;

	int idx = 0;
	lua_pushnil(L);
	while (lua_next(L, -2) != 0) {
		/* key at -2 (oneof name), value at -1 (members array) */
		const char *oname = lua_tostring(L, -2);
		p->oneofs[idx].name = strdup(oname != NULL ? oname : "");

		int n_members = (int)lua_objlen(L, -1);
		p->oneofs[idx].n_members = n_members;
		p->oneofs[idx].member_indices = (int *)calloc(n_members, sizeof(int));

		for (int j = 0; j < n_members; j++) {
			lua_rawgeti(L, -1, j + 1);
			const char *mname = lua_tostring(L, -1);
			int fi = find_field_by_name(p, L, field_names_idx,
			                            mname != NULL ? mname : "");
			lua_pop(L, 1);
			p->oneofs[idx].member_indices[j] = fi;
			if (fi >= 0)
				p->fields[fi].oneof_idx = idx;
		}

		lua_pop(L, 1); /* value */
		idx++;
	}
	lua_pop(L, 1); /* oneofs table */
}

/* ---------------------------------------------------------------- *
 *  Main compile entry.                                              *
 *                                                                  *
 *  Idempotent. If desc.c_plan exists and is a pb.plan userdata,    *
 *  returns it. Otherwise allocates a new one, stashes it on        *
 *  desc.c_plan BEFORE recursing into sub-plans (breaks cycles for  *
 *  self-referencing messages like Person.friends → Person), then   *
 *  walks fields and oneofs.                                        *
 * ---------------------------------------------------------------- */

/* Lua 5.1 lacks lua_absindex; LuaJIT's compatibility layer too. */
static inline int
abs_idx(lua_State *L, int idx)
{
	if (idx < 0 && idx > LUA_REGISTRYINDEX)
		return lua_gettop(L) + idx + 1;
	return idx;
}

static int
compile_plan_impl(lua_State *L, int desc_idx)
{
	desc_idx = abs_idx(L, desc_idx);

	/* Idempotency check. */
	lua_getfield(L, desc_idx, "c_plan");
	if (lua_isuserdata(L, -1)) {
		/* Already compiled; leave on top of stack and pop the original
		 * desc-table push pattern wasn't done — caller still owns. */
		return 1;
	}
	lua_pop(L, 1);

	/* Allocate the plan userdata and stash it immediately. */
	pb_plan *p = (pb_plan *)lua_newuserdata(L, sizeof(pb_plan));
	memset(p, 0, sizeof(*p));
	p->override_encode_ref = LUA_NOREF;
	p->override_decode_ref = LUA_NOREF;
	p->field_names_ref = LUA_NOREF;
	p->sub_plans_ref = LUA_NOREF;

	luaL_getmetatable(L, PB_PLAN_MT);
	lua_setmetatable(L, -2);
	int plan_idx = lua_gettop(L);

	/* Stash on desc.c_plan first to break sub-message cycles. */
	lua_pushvalue(L, plan_idx);
	lua_setfield(L, desc_idx, "c_plan");

	/* desc.name */
	lua_getfield(L, desc_idx, "name");
	const char *dname = lua_tostring(L, -1);
	p->name = strdup(dname != NULL ? dname : "");
	lua_pop(L, 1);

	/* WKT override pointers — desc.encode / desc.decode. */
	lua_getfield(L, desc_idx, "encode");
	if (lua_isfunction(L, -1)) {
		p->has_override = 1;
		p->override_encode_ref = luaL_ref(L, LUA_REGISTRYINDEX);
	} else {
		lua_pop(L, 1);
	}
	lua_getfield(L, desc_idx, "decode");
	if (lua_isfunction(L, -1)) {
		p->has_override = 1;
		p->override_decode_ref = luaL_ref(L, LUA_REGISTRYINDEX);
	} else {
		lua_pop(L, 1);
	}

	/* Extension ranges (proto2). desc.extension_ranges = {{start, end}, ...} */
	lua_getfield(L, desc_idx, "extension_ranges");
	if (lua_istable(L, -1) && lua_objlen(L, -1) >= 1) {
		lua_rawgeti(L, -1, 1);
		if (lua_istable(L, -1)) {
			lua_rawgeti(L, -1, 1);
			p->extension_range_start = (int)lua_tointeger(L, -1);
			lua_pop(L, 1);
			lua_rawgeti(L, -1, 2);
			p->extension_range_end = (int)lua_tointeger(L, -1);
			lua_pop(L, 1);
		}
		lua_pop(L, 1);
	}
	lua_pop(L, 1);

	/* If this descriptor has an override, we skip field-walk entirely —
	 * the override owns encode/decode and the field array is unused. */
	if (p->has_override) {
		return 1;
	}

	/* Create the field-names and sub-plans tables. */
	lua_newtable(L);
	int field_names_idx = lua_gettop(L);
	lua_newtable(L);
	int sub_plans_idx = lua_gettop(L);

	/* Walk desc.fields. */
	lua_getfield(L, desc_idx, "fields");
	if (!lua_istable(L, -1))
		luaL_error(L, "descriptor '%s' has no 'fields'",
		           p->name != NULL ? p->name : "?");
	int n_fields = (int)lua_objlen(L, -1);
	int fields_table_idx = lua_gettop(L);

	p->n_fields = n_fields;
	p->fields = (pb_plan_field *)calloc(
		n_fields > 0 ? n_fields : 1, sizeof(pb_plan_field));

	for (int i = 0; i < n_fields; i++) {
		lua_rawgeti(L, fields_table_idx, i + 1);
		int f_desc_idx = lua_gettop(L);
		compile_field(L, f_desc_idx, &p->fields[i],
		              field_names_idx, sub_plans_idx, i + 1);
		lua_pop(L, 1);
	}

	lua_pop(L, 1); /* fields table */

	/* Compile oneofs after fields so oneof_idx back-pointers can be set. */
	compile_oneofs(L, p, desc_idx, field_names_idx);

	/* Stash the field-names + sub-plans tables in the registry. */
	lua_pushvalue(L, sub_plans_idx);
	p->sub_plans_ref = luaL_ref(L, LUA_REGISTRYINDEX);
	lua_pop(L, 1); /* sub_plans_idx (now unreferenced from stack) */

	lua_pushvalue(L, field_names_idx);
	p->field_names_ref = luaL_ref(L, LUA_REGISTRYINDEX);
	lua_pop(L, 1);

	/* Stack now has just the plan userdata on top. */
	return 1;
}

static int
compile_plan_lua(lua_State *L)
{
	luaL_checktype(L, 1, LUA_TTABLE);
	lua_settop(L, 1);
	return compile_plan_impl(L, 1);
}

/* ---------------------------------------------------------------- *
 *  Introspection (for the smoke test and future debugging).        *
 *                                                                  *
 *  These are intentionally lean — enough to let a Lua test assert  *
 *  the plan-build produced sensible values without a C-side test   *
 *  harness.                                                         *
 * ---------------------------------------------------------------- */

static int
plan_n_fields(lua_State *L)
{
	pb_plan *p = (pb_plan *)luaL_checkudata(L, 1, PB_PLAN_MT);
	lua_pushinteger(L, p->n_fields);
	return 1;
}

static int
plan_name(lua_State *L)
{
	pb_plan *p = (pb_plan *)luaL_checkudata(L, 1, PB_PLAN_MT);
	lua_pushstring(L, p->name != NULL ? p->name : "");
	return 1;
}

static int
plan_field_info(lua_State *L)
{
	pb_plan *p = (pb_plan *)luaL_checkudata(L, 1, PB_PLAN_MT);
	int i = luaL_checkint(L, 2);
	if (i < 1 || i > p->n_fields)
		return luaL_error(L, "field index %d out of range [1, %d]",
		                  i, p->n_fields);
	pb_plan_field *f = &p->fields[i - 1];

	lua_createtable(L, 0, 9);

	lua_pushinteger(L, f->field_number);
	lua_setfield(L, -2, "field_number");

	lua_pushinteger(L, f->wire_type);
	lua_setfield(L, -2, "wire_type");

	lua_pushinteger(L, f->kind);
	lua_setfield(L, -2, "kind");

	lua_pushboolean(L, f->repeated);
	lua_setfield(L, -2, "repeated");

	lua_pushboolean(L, f->packed);
	lua_setfield(L, -2, "packed");

	lua_pushboolean(L, f->optional);
	lua_setfield(L, -2, "optional");

	lua_pushinteger(L, f->sub_plan_idx);
	lua_setfield(L, -2, "sub_plan_idx");

	lua_pushinteger(L, f->oneof_idx);
	lua_setfield(L, -2, "oneof_idx");

	lua_pushlstring(L, (const char *)f->tag_bytes, f->tag_len);
	lua_setfield(L, -2, "tag_bytes");

	/* Field name from cached table. */
	lua_rawgeti(L, LUA_REGISTRYINDEX, p->field_names_ref);
	lua_rawgeti(L, -1, i);
	lua_remove(L, -2);
	lua_setfield(L, -2, "name");

	/* Map kinds, if any. */
	if (f->kind == PB_KIND_MAP) {
		lua_pushinteger(L, f->map_key_kind);
		lua_setfield(L, -2, "map_key_kind");
		lua_pushinteger(L, f->map_value_kind);
		lua_setfield(L, -2, "map_value_kind");
		lua_pushinteger(L, f->map_value_sub_plan_idx);
		lua_setfield(L, -2, "map_value_sub_plan_idx");
	}

	return 1;
}

static int
plan_n_oneofs(lua_State *L)
{
	pb_plan *p = (pb_plan *)luaL_checkudata(L, 1, PB_PLAN_MT);
	lua_pushinteger(L, p->n_oneofs);
	return 1;
}

static int
plan_oneof_info(lua_State *L)
{
	pb_plan *p = (pb_plan *)luaL_checkudata(L, 1, PB_PLAN_MT);
	int i = luaL_checkint(L, 2);
	if (i < 1 || i > p->n_oneofs)
		return luaL_error(L, "oneof index %d out of range [1, %d]",
		                  i, p->n_oneofs);
	pb_plan_oneof *o = &p->oneofs[i - 1];

	lua_createtable(L, 0, 2);
	lua_pushstring(L, o->name != NULL ? o->name : "");
	lua_setfield(L, -2, "name");

	lua_createtable(L, o->n_members, 0);
	for (int j = 0; j < o->n_members; j++) {
		lua_pushinteger(L, o->member_indices[j]);
		lua_rawseti(L, -2, j + 1);
	}
	lua_setfield(L, -2, "member_indices");

	return 1;
}

static int
plan_has_override(lua_State *L)
{
	pb_plan *p = (pb_plan *)luaL_checkudata(L, 1, PB_PLAN_MT);
	lua_pushboolean(L, p->has_override);
	return 1;
}

static int
plan_sub_plan(lua_State *L)
{
	pb_plan *p = (pb_plan *)luaL_checkudata(L, 1, PB_PLAN_MT);
	int i = luaL_checkint(L, 2);
	if (p->sub_plans_ref == LUA_NOREF || i < 1) {
		lua_pushnil(L);
		return 1;
	}
	lua_rawgeti(L, LUA_REGISTRYINDEX, p->sub_plans_ref);
	lua_rawgeti(L, -1, i);
	lua_remove(L, -2);
	return 1;
}

/* ---------------------------------------------------------------- *
 *  Module entry.                                                    *
 * ---------------------------------------------------------------- */

static const struct luaL_Reg c_runtime_methods[] = {
	{"compile_plan",    compile_plan_lua},
	{"plan_n_fields",   plan_n_fields},
	{"plan_name",       plan_name},
	{"plan_field_info", plan_field_info},
	{"plan_n_oneofs",   plan_n_oneofs},
	{"plan_oneof_info", plan_oneof_info},
	{"plan_has_override", plan_has_override},
	{"plan_sub_plan",   plan_sub_plan},
	{NULL, NULL},
};

static const struct luaL_Reg plan_mt_methods[] = {
	{"__gc",       plan_gc},
	{"__tostring", plan_tostring},
	{NULL, NULL},
};

LUA_API int
luaopen_pb_c_runtime(lua_State *L)
{
	/* Register the plan metatable. */
	luaL_newmetatable(L, PB_PLAN_MT);
	luaL_register(L, NULL, plan_mt_methods);
	lua_pop(L, 1);

	/* Build the module table. */
	lua_newtable(L);
	luaL_register(L, NULL, c_runtime_methods);

	lua_pushliteral(L, PB_ABI_VERSION);
	lua_setfield(L, -2, "_abi_version");

	/* Kind constants — exported so Lua tests can compare without
	 * duplicating the enum. */
	lua_createtable(L, 0, 19);
	lua_pushinteger(L, PB_KIND_NONE);     lua_setfield(L, -2, "NONE");
	lua_pushinteger(L, PB_KIND_INT32);    lua_setfield(L, -2, "INT32");
	lua_pushinteger(L, PB_KIND_INT64);    lua_setfield(L, -2, "INT64");
	lua_pushinteger(L, PB_KIND_UINT32);   lua_setfield(L, -2, "UINT32");
	lua_pushinteger(L, PB_KIND_UINT64);   lua_setfield(L, -2, "UINT64");
	lua_pushinteger(L, PB_KIND_SINT32);   lua_setfield(L, -2, "SINT32");
	lua_pushinteger(L, PB_KIND_SINT64);   lua_setfield(L, -2, "SINT64");
	lua_pushinteger(L, PB_KIND_FIXED32);  lua_setfield(L, -2, "FIXED32");
	lua_pushinteger(L, PB_KIND_FIXED64);  lua_setfield(L, -2, "FIXED64");
	lua_pushinteger(L, PB_KIND_SFIXED32); lua_setfield(L, -2, "SFIXED32");
	lua_pushinteger(L, PB_KIND_SFIXED64); lua_setfield(L, -2, "SFIXED64");
	lua_pushinteger(L, PB_KIND_FLOAT);    lua_setfield(L, -2, "FLOAT");
	lua_pushinteger(L, PB_KIND_DOUBLE);   lua_setfield(L, -2, "DOUBLE");
	lua_pushinteger(L, PB_KIND_BOOL);     lua_setfield(L, -2, "BOOL");
	lua_pushinteger(L, PB_KIND_STRING);   lua_setfield(L, -2, "STRING");
	lua_pushinteger(L, PB_KIND_BYTES);    lua_setfield(L, -2, "BYTES");
	lua_pushinteger(L, PB_KIND_ENUM);     lua_setfield(L, -2, "ENUM");
	lua_pushinteger(L, PB_KIND_MESSAGE);  lua_setfield(L, -2, "MESSAGE");
	lua_pushinteger(L, PB_KIND_MAP);      lua_setfield(L, -2, "MAP");
	lua_setfield(L, -2, "KIND");

	lua_createtable(L, 0, 4);
	lua_pushinteger(L, PB_WIRE_VARINT); lua_setfield(L, -2, "VARINT");
	lua_pushinteger(L, PB_WIRE_I64);    lua_setfield(L, -2, "I64");
	lua_pushinteger(L, PB_WIRE_LEN);    lua_setfield(L, -2, "LEN");
	lua_pushinteger(L, PB_WIRE_I32);    lua_setfield(L, -2, "I32");
	lua_setfield(L, -2, "WIRE");

	return 1;
}

A test/c_runtime_plan_test.lua => test/c_runtime_plan_test.lua +210 -0
@@ 0,0 1,210 @@
-- Smoke test for bd-mq7: descriptor → C plan compiler.
--
-- Only runs when PB_ENABLE_C=1 is set in the environment AND the C
-- runtime module is loadable. Otherwise the group is skipped, which
-- keeps `just test` green on hosts without the C module built.
--
-- Acceptance per bd-mq7:
--   (1) pb.c_runtime.compile_plan(desc) returns a userdata
--   (2) plan->n_fields and plan->fields[i].tag are readable
--   (3) Plans for hello.Person and hello.Address build without error

local t = require('luatest')

local pb = require('pb')
local c_runtime = pb.c_runtime

local function skip_if_no_c()
    if c_runtime == nil then
        t.skip('PB_ENABLE_C not set or pb.c_runtime not available')
    end
end

-- Run each test against both codegen modes — c_plan is attached to the
-- descriptor regardless of mode, so both pick up the same compile path.
for _, mode in ipairs({'full', 'runtime'}) do
    local g = t.group('c_runtime_plan.' .. mode)
    local hello

    g.before_all(function()
        skip_if_no_c()
        hello = require(mode .. '.hello.hello_pb')
    end)

    g.before_each(skip_if_no_c)

    function g.test_module_surface()
        t.assert_equals(type(c_runtime.compile_plan), 'function')
        t.assert_equals(type(c_runtime._abi_version), 'string')
        t.assert(c_runtime.KIND ~= nil, 'KIND table exposed')
        t.assert(c_runtime.WIRE ~= nil, 'WIRE table exposed')
        t.assert_equals(c_runtime.WIRE.LEN, 2)
        t.assert_equals(c_runtime.WIRE.VARINT, 0)
    end

    function g.test_compile_address_returns_userdata()
        local plan = c_runtime.compile_plan(hello.Address_descriptor)
        t.assert_equals(type(plan), 'userdata')
        t.assert_equals(c_runtime.plan_name(plan), 'hello.Address')
        t.assert_equals(c_runtime.plan_n_fields(plan), 4)
    end

    function g.test_address_field_shapes()
        local plan = c_runtime.compile_plan(hello.Address_descriptor)
        -- {name="street", id=1, kind=scalar/string}
        local f1 = c_runtime.plan_field_info(plan, 1)
        t.assert_equals(f1.name, 'street')
        t.assert_equals(f1.field_number, 1)
        t.assert_equals(f1.wire_type, c_runtime.WIRE.LEN)
        t.assert_equals(f1.kind, c_runtime.KIND.STRING)
        t.assert_equals(f1.tag_bytes:byte(1, 1), 0x0A) -- (1<<3)|2
        -- {name="apartment", id=4, kind=scalar/string, optional=true}
        local f4 = c_runtime.plan_field_info(plan, 4)
        t.assert_equals(f4.name, 'apartment')
        t.assert_equals(f4.optional, true)
        t.assert_equals(f4.field_number, 4)
    end

    function g.test_compile_person_returns_userdata()
        local plan = c_runtime.compile_plan(hello.Person_descriptor)
        t.assert_equals(type(plan), 'userdata')
        t.assert_equals(c_runtime.plan_name(plan), 'hello.Person')
        -- Person has 14 fields per examples/proto/hello.proto
        t.assert_equals(c_runtime.plan_n_fields(plan), 14)
    end

    function g.test_person_scalar_fields()
        local plan = c_runtime.compile_plan(hello.Person_descriptor)
        -- name = string @1
        local f = c_runtime.plan_field_info(plan, 1)
        t.assert_equals(f.name, 'name')
        t.assert_equals(f.kind, c_runtime.KIND.STRING)
        t.assert_equals(f.wire_type, c_runtime.WIRE.LEN)
        -- age = int32 @2
        f = c_runtime.plan_field_info(plan, 2)
        t.assert_equals(f.name, 'age')
        t.assert_equals(f.kind, c_runtime.KIND.INT32)
        t.assert_equals(f.wire_type, c_runtime.WIRE.VARINT)
        -- user_id = fixed64 @9
        f = c_runtime.plan_field_info(plan, 9)
        t.assert_equals(f.name, 'user_id')
        t.assert_equals(f.kind, c_runtime.KIND.FIXED64)
        t.assert_equals(f.wire_type, c_runtime.WIRE.I64)
        -- balance = sint32 @10
        f = c_runtime.plan_field_info(plan, 10)
        t.assert_equals(f.kind, c_runtime.KIND.SINT32)
        -- weight_kg = double @11
        f = c_runtime.plan_field_info(plan, 11)
        t.assert_equals(f.kind, c_runtime.KIND.DOUBLE)
        t.assert_equals(f.wire_type, c_runtime.WIRE.I64)
    end

    function g.test_person_repeated_packed()
        local plan = c_runtime.compile_plan(hello.Person_descriptor)
        -- emails = repeated string @3 (not packed)
        local f = c_runtime.plan_field_info(plan, 3)
        t.assert_equals(f.name, 'emails')
        t.assert_equals(f.repeated, true)
        t.assert_equals(f.packed, false)
        t.assert_equals(f.wire_type, c_runtime.WIRE.LEN)
        -- lucky_numbers = repeated int32, packed @7
        f = c_runtime.plan_field_info(plan, 7)
        t.assert_equals(f.name, 'lucky_numbers')
        t.assert_equals(f.repeated, true)
        t.assert_equals(f.packed, true)
        -- packed flips wire type to LEN regardless of element type
        t.assert_equals(f.wire_type, c_runtime.WIRE.LEN)
        t.assert_equals(f.kind, c_runtime.KIND.INT32)
    end

    function g.test_person_enum_field()
        local plan = c_runtime.compile_plan(hello.Person_descriptor)
        -- status = enum @4
        local f = c_runtime.plan_field_info(plan, 4)
        t.assert_equals(f.name, 'status')
        t.assert_equals(f.kind, c_runtime.KIND.ENUM)
        t.assert_equals(f.wire_type, c_runtime.WIRE.VARINT)
    end

    function g.test_person_message_field_resolves_sub_plan()
        local plan = c_runtime.compile_plan(hello.Person_descriptor)
        -- address = message Address @5
        local f = c_runtime.plan_field_info(plan, 5)
        t.assert_equals(f.name, 'address')
        t.assert_equals(f.kind, c_runtime.KIND.MESSAGE)
        t.assert(f.sub_plan_idx > 0, 'sub_plan_idx populated')
        local sub = c_runtime.plan_sub_plan(plan, f.sub_plan_idx)
        t.assert_equals(type(sub), 'userdata')
        t.assert_equals(c_runtime.plan_name(sub), 'hello.Address')
    end

    function g.test_self_reference_cycle()
        local plan = c_runtime.compile_plan(hello.Person_descriptor)
        -- friends = repeated Person @6 (self-reference)
        local f = c_runtime.plan_field_info(plan, 6)
        t.assert_equals(f.name, 'friends')
        t.assert_equals(f.kind, c_runtime.KIND.MESSAGE)
        t.assert_equals(f.repeated, true)
        t.assert(f.sub_plan_idx > 0)
        local sub = c_runtime.plan_sub_plan(plan, f.sub_plan_idx)
        t.assert_equals(c_runtime.plan_name(sub), 'hello.Person')
        -- Cycle resolves to the same userdata, not a fresh one
        t.assert(sub == plan,
                 'self-reference returns the same plan userdata (cycle broken)')
    end

    function g.test_person_map_field()
        local plan = c_runtime.compile_plan(hello.Person_descriptor)
        -- ages_by_nickname: map<string, int32> @13
        local f = c_runtime.plan_field_info(plan, 12)  -- 12th field
        t.assert_equals(f.name, 'ages_by_nickname')
        t.assert_equals(f.kind, c_runtime.KIND.MAP)
        t.assert_equals(f.map_key_kind, c_runtime.KIND.STRING)
        t.assert_equals(f.map_value_kind, c_runtime.KIND.INT32)
        -- addresses_by_label: map<string, Address> @15
        f = c_runtime.plan_field_info(plan, 14)
        t.assert_equals(f.name, 'addresses_by_label')
        t.assert_equals(f.map_value_kind, c_runtime.KIND.MESSAGE)
        t.assert(f.map_value_sub_plan_idx > 0)
        local sub = c_runtime.plan_sub_plan(plan, f.map_value_sub_plan_idx)
        t.assert_equals(c_runtime.plan_name(sub), 'hello.Address')
    end

    function g.test_idempotent_compile()
        local p1 = c_runtime.compile_plan(hello.Person_descriptor)
        local p2 = c_runtime.compile_plan(hello.Person_descriptor)
        t.assert(p1 == p2, 'second compile returns cached plan')
    end

    function g.test_result_oneof()
        local plan = c_runtime.compile_plan(hello.Result_descriptor)
        t.assert_equals(c_runtime.plan_n_fields(plan), 4)
        t.assert_equals(c_runtime.plan_n_oneofs(plan), 1)
        local o = c_runtime.plan_oneof_info(plan, 1)
        t.assert_equals(o.name, 'outcome')
        t.assert_equals(#o.member_indices, 3)
        -- Members are 0-based indices into plan->fields[]; text/code/details
        -- are fields 2, 3, 4 in Result (id-ordered) → indices 1, 2, 3.
        local idxs = {}
        for _, i in ipairs(o.member_indices) do idxs[i] = true end
        t.assert(idxs[1] and idxs[2] and idxs[3],
                 'oneof members map to text/code/details indices')
        -- Each member field gets oneof_idx = 0 (the only oneof in Result).
        for _, fi in ipairs({2, 3, 4}) do
            local f = c_runtime.plan_field_info(plan, fi)
            t.assert_equals(f.oneof_idx, 0,
                            ('field %d oneof_idx'):format(fi))
        end
        -- id (field 1) is NOT in a oneof.
        local f1 = c_runtime.plan_field_info(plan, 1)
        t.assert_equals(f1.oneof_idx, -1)
    end

    function g.test_wkt_override_detection()
        local wkt = pb.wkt
        local plan = c_runtime.compile_plan(wkt.Timestamp_descriptor)
        t.assert_equals(c_runtime.plan_has_override(plan), true)
        -- has_override means the plan does not walk fields; field count is 0.
        t.assert_equals(c_runtime.plan_n_fields(plan), 0)
    end
end