- branch 1 output contract targets typed Rust structs (no Value materialization): the kernel-fusion move, alkminer precedent - russh-sftp protocol-type shape as target (plain structs, field order = decode order, shared cursor codec core) - fork: (a) serde-derive + generic Deserializer (interop) vs (b) generated direct codecs (zero overhead, the 72ns shape) - follow-up #1 sharpened: typed-output POC renders Packet both ways, benchmarks against the raw-decode baseline
14 KiB
status, last_updated
| status | last_updated |
|---|---|
| complete | 2026-09-08 |
alkgen: Reader POC Research Summary
Status: Research complete — the plan-driven handlebars codegen shape
validated end-to-end: a generated, self-contained Rust reader (no
use alktype) built at cargo build time from an alktype ReadPlan-
derived context, byte-parity-exact against alktype's own packed
materializer across a multi-buffer corpus, and ~1.9x faster than the
interpreter at identical output shape. 7 parity tests pass. The
remaining unknowns are spec-scope (Phase 1 ADRs), not feasibility.
Date: 2026-09-08
Scope: Validates OQ-GN-08 #1 (the reader POC), resolving the
OQ-GN-05/06/07 cluster (delegation boundary, dependency shape,
validation strategy) with executable evidence.
Executive Summary
A standalone POC crate (/workspace/alkgen-reader-poc) validated the
alkgen branch-1 mechanism end-to-end:
- Plan-driven context projection works (OQ-GN-02 resolved by
construction) — the
ReadPlancarries everything the generated code needs: dispatch kinds, effective endianness (already propagated per node), array counts and true strides, union discriminators with the integer dispatch table, and compile-timefixed_sizefor every struct node. The BAST document is needed only for descriptive metadata the plan doesn't carry (nothing in the POC scope). - Self-contained generated code works and is fast (OQ-GN-05/06
leaning (a) confirmed) — the generated reader has zero alktype
dependency, compiles clean, and runs 1.9x faster than
materialize_packedwith byte-parity on all parity fixtures. - Parity discipline — three-way equality: generated reader vs
alktype's
materialize_packedvs a hand-rolled decoder, over a fixed corpus (typical buffer, max-value buffer, empty-strings buffer, binary-payload variant). All four fixtures agree exactly, including the interpreter's semantic quirks (see §Parity semantics).
Benchmark (criterion, stable across 3 runs)
| Path | Time | Throughput |
|---|---|---|
| Hand-rolled raw decode (no Value building) | 72 ns | 1.67 GiB/s |
Hand-rolled, same Value output shape |
2.4–2.7 µs | ~52 MiB/s |
| Generated reader | 3.0–3.3 µs | ~38 MiB/s |
alktype interpreter (materialize_packed) |
5.5 µs | ~22 MiB/s |
Interpretation:
- Generated is ~1.9x faster than the interpreter at identical output shape — the compile-time dispatch win is real, though far from the ~20x hand-rolled number cited in Phase 0 discussion.
- The 20x gap is not dispatch overhead. Hand-rolled code that
builds the same
serde_json::Valueoutput runs 2.4–2.7 µs — only ~10–20% faster than generated. The 20x-vs-raw number is the cost ofValue/Map/Stringallocation (paid equally by any Value-materializing path), not the interpreter's per-node walk. - Implication for alkgen's success metric (OQ-GN-03): "recovering
most of the 20x" is only meaningful at equal output contract. For
the current materialize-to-
Valuecontract, generated code is already at ~90% of what hand-written code can do — the remaining interpreter-vs-generated gap (~1.9x) is the win codegen delivers. A future typed-struct output (noValuematerialization at all) is where the 20x-scale gains live; that is an output-contract decision for Phase 1 (see §Follow-ups).
Typed output direction set 2026-09-08 (POC debrief): branch 1's
output contract should be typed Rust structs, in the russh-sftp
protocol-type shape. The decomposition above made the Value
contract's ceiling visible; the next target is to generate the
hand-rolled shape itself — helpers + templates producing direct
wire codecs over typed structs, no Value anywhere. Conceptually
the alkminer-v1 kernel-fusion move: the schema compiles to native
code instead of driving a runtime walk.
Target shape (/workspace/russh-sftp/src/protocol/*.rs precedent):
per-type plain structs with named fields (pub struct Close { pub id: u32, pub handle: String } + macro impls), field order = decode
order, one shared cursor-based codec core. BAST kinds map to Rust
types cleanly (uint32→u32, string→String, struct→struct,
TUnion→enum). russh-sftp rides serde derive with a generic wire
Deserializer; BAST's per-field endianness and TUnion byte-
discriminators don't fit serde's derive directly, so the typed path
forks:
- (a) serde-derive architecture (russh-sftp's): one generic
Deserializer+ per-typeDeserializeimpls — derived where the wire is homogeneous; generated impls (or endian newtype wrappers) where it isn't; TUnion → generated enum with a custom impl. Pro: ecosystem interop (serdeconsumers,de::from_bytes::<T>ergonomics). Con: serde machinery overhead vs direct code; the endian/union cases get wrapper-heavy. - (b) generated direct codecs:
fn decode(buf: &mut Cursor) -> Result<Packet, GenError>per type — literally the hand-rolled 72 ns shape, template-generated. Pro: zero overhead, full control; the POC's error-returning cursor helpers carry over directly. Con: serde interop for typed output needs a bridge (or a later feature generating impls).
Either way, the benchmark target is the raw-decode baseline (72 ns): typed generated decode should land within striking distance, recovering the ~20x claim at the typed contract. This is branch 1's output-contract ADR (Phase 1's biggest), now with a concrete target shape and two implementation strategies. A typed-output POC (follow-up #1) can resolve (a) vs (b) cheaply by rendering the same Packet schema both ways and re-benchmarking.
Parity semantics the generated code must reproduce
Discovered by exercising the interpreter, all pinned in the parity tests:
- Bytes materialize as
Value::Arrayof u32s (not a binary representation) — generated code must match (bytes_valuehelper). - Byte-disc unions: discriminator stringified-integer lookup
(the plan's
int_keysgives compile-time dispatch), variant fields flattened into the union object under"__discriminator"(number), variant offset = discriminator end. - Fixed-stride arrays: plan's
element_stride(true stride for fixed elements, e.g. 4 forPoint); variable-length elements get stride 0 → sequential walk. - Empty datagrams/negative cases: short buffers must produce clean errors, not panics (see §Findings F-2).
- Cursor-uniform addressing: all fields (fixed and variable)
read at the running cursor; constant cursor increments fold in
LLVM — source-level static offset expressions are unnecessary
(and a first draft's
base + k/cursplit was buggy; see F-1).
Findings
F-1: Pure-cursor addressing beats source-level offset folding
First draft folded static offsets (base + k for fixed fields,
cursor only after the first variable field). Two bugs: fields after
variable fields mis-tracked, and nested-struct consumed-returns were
wrong. Rewriting to uniform cursor addressing (cur + k for
every field, cur += n always; nested fns start cur = base)
made the generator simpler (no static/dynamic phase tracking in the
context builder) and correctness obvious. LLVM folds the constant
chains to the same machine code — the compile-time win comes from
compiled dispatch, not source offset folding. Carry into alkgen:
the template model should be pure-cursor; do not track static/dynamic
phases in context builders.
F-2: Generated code must return errors, not panic
The interpreter returns AlkTypeError::Access on short buffers;
first-draft generated code panicked on slice indexing (the
range end index 35 out of range failure). Fixed by bounds-checking
runtime helpers returning ReadResult<T> with ? propagation —
generated functions are Result-returning. Carry into alkgen:
generated readers/writers must be error-returning end-to-end
(alkcall accepts BAST from arbitrary peers; AGENTS.md convention 2
applies to generated code too). Open question for Phase 1: the
error type shape (POC used String; alkgen likely wants a typed
error mirroring AlkTypeError::Access's shape without the alktype
dependency — or generated-code-local types).
F-3: Recursive context, flat render — partials + per-field dispatch template
The natural shape is: context builder recursively emits one
FnCtx per struct/variant (nested structs and union variants
become their own functions), and the template renders a uniform
function body: field statements in document order, each a
{ read; advance } statement from a small partial. Two template
anti-patterns from first drafts, worth encoding as alkgen guidance:
- Threading offset expressions through template context
(string-built
offset_expr) is fragile — cursor-based field statements need no offset math in templates at all. - Partials with dynamic
{{> (lookup ...)}}dispatch don't fit; a single field-statement partial with{{#if}}arms on the field's kind flags is sufficient and strict-mode-safe.
F-4: The dev-tool integration shape (build.rs) works
The POC's build.rs = schema JSON + template + context builder →
OUT_DIR/packet_reader.rs, include!-ed in tests — is exactly the
OQ-GN-01 dev-tool model at crate-internal scale. It exercised:
strict-mode rendering (caught a stale root.fields context key as a
render error — strict mode paying off), template registration as
data (typed errors, no unwraps in render path), and deterministic
output. The generated file starts with the DO-NOT-EDIT banner.
F-5: Coverage scope and what the POC did not exercise
POC scope (validated): fixed primitives all widths + bool + f32/f64,
length-prefixed strings/bytes, enum (u32), nested fixed struct,
fixed-stride primitive array, fixed-stride struct-element array,
variable-stride string array, byte-disc union with struct variants
(int dispatch). NOT exercised (Phase 1 residue): field-disc unions
(shared-field prefix walk), nested unions, records, offset-indirect
encoding, maxLength reservations, field-level endian overrides
(schema had none; the plan carries them but the template context
only used the effective endian — which is correct), big-endian
schemas, aligned mode (OffsetMap path — this POC was packed mode
only). None of these are expected to break the shape; they add
template arms.
POC structure (for reference)
/workspace/alkgen-reader-poc/
├── schema/packet.json # sample BAST document (Packet + Point + Payload)
├── templates/reader.hbs # runtime helpers + function bodies
├── templates/partial_field_stmt.hbs # per-field statement dispatch
├── build.rs # plan compile → context build → render
├── src/wire.rs # known-good hand encoder (test corpus)
├── tests/parity.rs # 3-way parity fixtures (7 tests)
├── benches/three_way.rs # 4-way benchmark (raw + value-shaped baselines)
└── include/packet_reader.rs # pinned copy of generated output
The include/packet_reader.rs copy exists because build-script
output lives in OUT_DIR; alkgen proper will emit into the
consumer's source tree (dev-tool model) — or a build-script helper
in alkgen — either way the include-file dance is a POC artifact, not
a design constraint.
OQ resolution status after POC
- OQ-GN-05 (delegation boundary): resolved — self-contained generated code, no runtime alktype calls; alktype appears only in the generator (build script). The (a) posture is executable and faster.
- OQ-GN-06 (dependency shape): resolved for branch 1 — generated
output depends only on
serde_json(the Value contract). Caveat: if branch 1 later wants typed-struct output (no Value), generated code would depend only onserdederive — or nothing. That is an output-contract decision (§Follow-ups). - OQ-GN-07 (validation strategy): materially informed — the POC
validates reads (bounds checks, UTF-8, union dispatch) as generated
error-returning code. Full BAST validation (jsonschema-derived
semantic checks per
ValidationPlan) was not exercised; still open, but the drift-risk mitigation is now demonstrated for the read path. - OQ-GN-02 (context source): plan-driven projection confirmed workable; the plan carried 100% of the semantic content in POC scope.
Follow-ups (Phase 1 input)
- Output contract ADR — typed-struct output (russh-sftp shape,
§"Typed output direction") vs
Value-materializing. The typed direction is set; the ADR decides the serde-derive (a) vs direct codec (b) fork. A typed-output POC resolves it by rendering the Packet schema both ways and re-benchmarking against the 72 ns raw-decode baseline. - Typed error shape for generated code (F-2) — mirror
AlkTypeErrorvariants without the alktype dep, or a generated-code-local enum. - Template asset organization — the partial-per-statement pattern (F-3) scales; enumerate the full arm set (records, offset-indirect, unions, BE) as a template inventory task.
- Writer generation — the write path mirrors the reader shape
(cursor-based,
Result); likely a template sibling with no new context projection (the plan's write side needs a small survey of alktype's write paths). - Validation strategy (OQ-GN-07) — decide whether generated
validators re-express
ValidationPlanchecks or call into jsonschema.
Verification
cargo test(POC crate): 7 passed (4 parity + 3 debug-intent tests pinned as executable documentation).cargo bench --bench three_way: stable numbers above.- Generated code compiles warning-clean (allow-dead-code banner); parity tests run it against the interpreter's own output.