docs(research): add alknet-typedef findings with POC results
POC 1 (core offset computation) and POC 2 (russh-sftp round-trip) are complete with 26 passing tests. Key architectural finding: two layout modes are needed — packed sequential for protocol wire formats (LayoutBuilder/SequentialReader) and aligned static for mmap-friendly formats (OffsetMap). The jsonschema crate's custom keyword API handles all 16 TypeDef:* kinds. TUnion byte-offset discriminator dispatch confirmed against russh-sftp's own serialization byte-for-byte.
This commit is contained in:
1 parent
3543c1bb7a
commit
076d5adfec
1 file changed
+889
@@ -0,0 +1,889 @@
|
||||
---
|
||||
status: poc-complete
|
||||
last_updated: 2026-07-20
|
||||
---
|
||||
|
||||
# alknet-typedef — Findings: JSON Schema as the binary struct engine
|
||||
|
||||
**Status:** Draft findings, iterating. Per the research-then-sync
|
||||
pattern (see `docs/research/stream-unification/findings.md` for the
|
||||
precedent), this doc iterates in `docs/research/`; we fix
|
||||
inter-document drift here, then sync to `docs/architecture/` and the
|
||||
ADRs only after it settles.
|
||||
|
||||
**Scope:** A small Rust crate (`alknet-typedef`) that takes a JSON
|
||||
Schema with `TypeDef:*` custom keywords and produces an offset map,
|
||||
read/write functions, and validation — all driven by the schema. The
|
||||
schema is the format definition; the engine is generic. This is the
|
||||
binary struct engine that metatensor, binary call frames, SFTP
|
||||
packets, and TTY negotiation all consume.
|
||||
|
||||
**Date:** 2026-07-20
|
||||
|
||||
**Origin:** The call-channels-unification findings
|
||||
(`docs/research/call-channels-unification/findings.md` §"alknet-typedef:
|
||||
JSON Schema as the binary struct engine") identified the convergence of
|
||||
three threads: the `typedef.ts` schema kinds from TypeBox, the
|
||||
russh-sftp protocol packets, and the metatensor format. The common
|
||||
pattern: a JSON Schema describes the shape of binary data, and the
|
||||
binary data is the struct's bytes at computed offsets. Two prior
|
||||
attempts (typebox-rs, alktype) built their own jsonschema engines — the
|
||||
fatal flaw. The `jsonschema` crate (v0.46.5, Draft 2020-12) already
|
||||
handles validation with custom keyword support; the novel code is the
|
||||
offset computation.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
`alknet-typedef` is a ~1,900-line Rust crate (POC verified). It takes a
|
||||
JSON Schema with `TypeDef:*` custom keywords (the same kinds defined in
|
||||
`typedef.ts`) and produces:
|
||||
|
||||
1. **An offset map** — walks the schema, computes byte offsets for each
|
||||
field based on type sizes, field order, and alignment.
|
||||
2. **Read/write functions** — given a `&[u8]` buffer and a field path,
|
||||
read the field's bytes at its offset (zero-copy for fixed-size
|
||||
types). Given a `&mut [u8]` buffer, write a value at its offset.
|
||||
3. **Validation** — via `jsonschema` custom keywords, validates that a
|
||||
buffer's bytes match the schema's type constraints.
|
||||
|
||||
The heavy lifting is done by the `jsonschema` crate (validation) and
|
||||
`serde_json` (schema parsing). The novel code is the offset computation
|
||||
— a recursive walk of the schema JSON that computes byte positions for
|
||||
each field. The custom keyword implementations are ~10 lines each.
|
||||
|
||||
**The schema is the format.** A JSON Schema with `TypeDef:Float32`,
|
||||
`TypeDef:Struct`, `TypeDef:Union` etc. is both the validation spec and
|
||||
the layout spec. No separate format definition, no separate parser, no
|
||||
separate validator. One schema, three uses: validate, compute offsets,
|
||||
access data.
|
||||
|
||||
**Variable-length types default to inline length-prefixing**
|
||||
(`[length: u32][data]`) — the universal pattern used by channels, SFTP,
|
||||
TTY, and most binary protocols. Offset indirection (the metatensor blob
|
||||
tensor pattern) is an opt-in alternative for mmap-friendly layouts.
|
||||
|
||||
**TUnion supports both byte-offset and field-name discriminators.**
|
||||
The byte-offset variant (`"discriminator": {"kind": "byte", "offset":
|
||||
0, "type": "Uint8"}`) handles protocol dispatch (SFTP type bytes, call
|
||||
protocol event types). The field-name variant (`"discriminator":
|
||||
{"kind": "field", "name": "type"}`) handles the typedef.ts string
|
||||
pattern.
|
||||
|
||||
**Key architectural finding: two layout modes are needed.** The POCs
|
||||
surfaced that protocols and mmap-friendly formats need different layout
|
||||
strategies:
|
||||
|
||||
- **Packed sequential layout** (`LayoutBuilder` / `SequentialReader`) —
|
||||
for protocol wire formats (SFTP, channels, TTY). Fields are packed
|
||||
with no alignment padding. Variable-length fields shift all subsequent
|
||||
fields. Writing requires knowing actual data sizes upfront; reading
|
||||
walks the buffer sequentially, reading length prefixes to determine
|
||||
positions.
|
||||
|
||||
- **Aligned static layout** (`OffsetMap`) — for mmap-friendly formats
|
||||
(metatensor). Fields have fixed positions with natural alignment
|
||||
padding. Variable-length fields get a 4-byte length prefix at a known
|
||||
offset; the variable data is not included in the static layout.
|
||||
|
||||
**POC results (26 tests, all passing):**
|
||||
- POC 1 (core): Fixed-size read/write round-trip, alignment padding,
|
||||
nested structs, variable-length strings, jsonschema custom keyword
|
||||
validation for all 16 `TypeDef:*` kinds.
|
||||
- POC 2 (SFTP): Byte-identical round-trip against russh-sftp for Read,
|
||||
Write, and Status packets. TUnion byte-offset discriminator dispatch.
|
||||
Big-endian integer encoding. Mixed fixed/variable fields in a single
|
||||
struct.
|
||||
|
||||
---
|
||||
|
||||
## The Schema Layer: TypeBox ↔ jsonschema
|
||||
|
||||
### Why this works
|
||||
|
||||
TypeBox modules render to standard JSON Schema under `$defs`. A TypeBox
|
||||
schema like:
|
||||
|
||||
```typescript
|
||||
const TensorRef = Type.Object({
|
||||
dtype: Type.Union([Type.Literal("F32"), Type.Literal("I16"), ...]),
|
||||
shape: Type.Array(Type.Number()),
|
||||
data_offsets: Type.Tuple([Type.Number(), Type.Number()])
|
||||
});
|
||||
```
|
||||
|
||||
serialized to JSON is:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dtype": { "type": "string", "enum": ["F32", "I16", ...] },
|
||||
"shape": { "type": "array", "items": { "type": "number" } },
|
||||
"data_offsets": { "type": "array", "minItems": 2, "maxItems": 2, ... }
|
||||
},
|
||||
"required": ["dtype", "shape", "data_offsets"]
|
||||
}
|
||||
```
|
||||
|
||||
That JSON feeds directly into `jsonschema::validator_for(&schema)` on
|
||||
the Rust side. Zero translation. The same schema validates in both
|
||||
ecosystems.
|
||||
|
||||
### Custom type kinds (typedef.ts port)
|
||||
|
||||
`typedef.ts` (`/workspace/@alkdev/typebox/example/typedef/typedef.ts`,
|
||||
619 lines) defines custom TypeBox schema kinds that carry *binary
|
||||
layout* semantics:
|
||||
|
||||
| Kind | TypeBox key | Rust type | Size |
|
||||
|------|-------------|-----------|------|
|
||||
| `TFloat32` | `TypeDef:Float32` | `f32` | 4 |
|
||||
| `TFloat64` | `TypeDef:Float64` | `f64` | 8 |
|
||||
| `TInt8` | `TypeDef:Int8` | `i8` | 1 |
|
||||
| `TInt16` | `TypeDef:Int16` | `i16` | 2 |
|
||||
| `TInt32` | `TypeDef:Int32` | `i32` | 4 |
|
||||
| `TUint8` | `TypeDef:Uint8` | `u8` | 1 |
|
||||
| `TUint16` | `TypeDef:Uint16` | `u16` | 2 |
|
||||
| `TUint32` | `TypeDef:Uint32` | `u32` | 4 |
|
||||
| `TString` | `TypeDef:String` | length-prefixed UTF-8 | variable |
|
||||
| `TStruct` | `TypeDef:Struct` | record of fields | sum of field sizes |
|
||||
| `TUnion` | `TypeDef:Union` | tagged union | discriminator + variant |
|
||||
| `TArray` | `TypeDef:Array` | repeated element | count × element size |
|
||||
| `TEnum` | `TypeDef:Enum` | string enum | variable |
|
||||
| `TRecord` | `TypeDef:Record` | string-keyed map | variable |
|
||||
| `TBoolean` | `TypeDef:Boolean` | `bool` | 1 |
|
||||
| `TTimestamp` | `TypeDef:Timestamp` | ISO 8601 string | variable |
|
||||
|
||||
These are registered in TypeBox via `TypeRegistry.Set` with custom
|
||||
validators. The Rust `jsonschema` crate supports the same pattern via
|
||||
`with_keyword("TypeDef:Float32", factory)` — each `TypeDef:*` kind maps
|
||||
to a custom keyword validator in Rust. Same semantics, different
|
||||
language, same JSON Schema wire format.
|
||||
|
||||
### The jsonschema crate's custom keyword API
|
||||
|
||||
The `jsonschema` crate (v0.46.5, Draft 2020-12 default) is already in
|
||||
the workspace at `/workspace/jsonschema/` but not yet used by any
|
||||
alknet crate. It supports custom keywords via:
|
||||
|
||||
```rust
|
||||
pub trait Keyword: Send + Sync {
|
||||
fn validate<'i>(&self, instance: &'i Value) -> Result<(), ValidationError<'i>>;
|
||||
fn is_valid(&self, instance: &Value) -> bool;
|
||||
}
|
||||
```
|
||||
|
||||
Registration:
|
||||
|
||||
```rust
|
||||
let validator = jsonschema::options()
|
||||
.with_keyword("TypeDef:Float32", |parent, value, path| {
|
||||
Ok(Box::new(Float32Validator))
|
||||
})
|
||||
.with_keyword("TypeDef:Int32", |parent, value, path| {
|
||||
Ok(Box::new(Int32Validator))
|
||||
})
|
||||
.with_keyword("TypeDef:Struct", |parent, value, path| {
|
||||
Ok(Box::new(StructValidator::from_schema(parent)?))
|
||||
})
|
||||
.build(&schema)?;
|
||||
```
|
||||
|
||||
The factory closure receives the parent schema object, the keyword's
|
||||
value, and the schema path — enabling cross-keyword awareness (e.g., a
|
||||
custom `"minimum"` keyword that inspects the parent's `"format"` field).
|
||||
|
||||
Custom formats are also supported via `with_format("f32", |s| ...)`.
|
||||
|
||||
### What this eliminates
|
||||
|
||||
**typebox-rs** (`/workspace/@alkimiadev/typebox-rs/`, ~8,400 lines) and
|
||||
**alktype** (`/workspace/@alkimiadev/alktype/`, ~5,600 lines) both
|
||||
built their own jsonschema engines — the fatal flaw. typebox-rs has a
|
||||
full 26-variant `SchemaKind` enum, a custom `Value` type with typed
|
||||
arrays, and a 912-line hand-written validator. alktype uses a handler
|
||||
registry pattern but also implements its own validation for each type.
|
||||
|
||||
Both are replaced by:
|
||||
- `jsonschema` crate — validation, custom keywords, custom formats,
|
||||
compiled validators
|
||||
- A small offset-computation module — walks a schema, computes byte
|
||||
offsets for each field based on type sizes (the one piece of real
|
||||
logic beyond jsonschema)
|
||||
- Custom keyword implementations — the `TypeDef:Float32` /
|
||||
`TypeDef:Int32` / `TypeDef:Struct` validators (small functions, ~10
|
||||
lines each)
|
||||
|
||||
No hand-rolled schema type system, no builder, no registry. The
|
||||
codebase drops from "a port of TypeBox" to "jsonschema + an offset map
|
||||
+ ~50 lines of custom keyword implementations."
|
||||
|
||||
The layout computation from typebox-rs (`layout.rs`, 193 lines) is the
|
||||
salvageable part — the offset algorithm is correct and can be adapted
|
||||
to work with jsonschema's parsed JSON rather than typebox-rs's own
|
||||
`SchemaKind` enum.
|
||||
|
||||
---
|
||||
|
||||
## The Three Core Capabilities
|
||||
|
||||
### 1. Offset Computation
|
||||
|
||||
The novel code. Given a JSON Schema with `TypeDef:*` keywords, walk the
|
||||
schema tree and compute the byte offset and size of each field.
|
||||
|
||||
```rust
|
||||
struct OffsetMap {
|
||||
fields: Vec<(String, ByteRange)>,
|
||||
total_size: usize,
|
||||
}
|
||||
|
||||
struct ByteRange {
|
||||
start: usize,
|
||||
end: usize,
|
||||
}
|
||||
|
||||
fn compute_offsets(schema: &serde_json::Value) -> OffsetMap {
|
||||
// Walk the schema:
|
||||
// - TypeDef:Float32 → 4 bytes, align 4
|
||||
// - TypeDef:Int32 → 4 bytes, align 4
|
||||
// - TypeDef:Uint8 → 1 byte, align 1
|
||||
// - TypeDef:Struct → recurse, sum field sizes, align to max field alignment
|
||||
// - TypeDef:Array of fixed-size T → element_size * count
|
||||
// - TypeDef:String (length-prefixed) → 4 (length prefix), align 4
|
||||
// - TypeDef:String (offset-indirect) → 0 (variable, handled by consumer)
|
||||
// - TypeDef:Union → discriminator size + max variant size
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
The offset computation is schema-driven: the schema's type kinds
|
||||
determine byte sizes, the struct's field order determines offsets,
|
||||
alignment annotations (or defaults) determine padding. The output is an
|
||||
`OffsetMap` — a flat table of `(field_path, byte_range)` pairs.
|
||||
|
||||
For fixed-size types, the offset is computed from the schema alone. For
|
||||
variable-length types with inline length-prefixing, the fixed part (the
|
||||
4-byte length prefix) gets an offset; the variable part is accessed via
|
||||
the length prefix at runtime. For offset-indirection, the offset is
|
||||
stored *in* the data (the `{offset, length}` pair in the index struct).
|
||||
|
||||
### 2. Read/Write
|
||||
|
||||
Given a `&[u8]` buffer and a field path, read the field's bytes at its
|
||||
offset:
|
||||
|
||||
```rust
|
||||
impl OffsetMap {
|
||||
fn read_f32(&self, buffer: &[u8], field_path: &str) -> f32 {
|
||||
let range = self.fields.get(field_path).unwrap();
|
||||
f32::from_le_bytes(buffer[range.start..range.end].try_into().unwrap())
|
||||
}
|
||||
|
||||
fn read_str<'a>(&self, buffer: &'a [u8], field_path: &str) -> &'a str {
|
||||
let range = self.fields.get(field_path).unwrap();
|
||||
let len = u32::from_le_bytes(buffer[range.start..range.start+4].try_into().unwrap()) as usize;
|
||||
std::str::from_utf8(&buffer[range.start+4..range.start+4+len]).unwrap()
|
||||
}
|
||||
|
||||
fn write_u32(&self, buffer: &mut [u8], field_path: &str, value: u32) {
|
||||
let range = self.fields.get(field_path).unwrap();
|
||||
buffer[range.start..range.end].copy_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Fixed-size types: zero-copy pointer cast (`&[f32]`, `&[u8; 4]`).
|
||||
Variable-length types with inline length-prefixing: read the 4-byte
|
||||
length prefix at the field's offset, then slice the data that follows.
|
||||
Offset-indirection: the consumer provides the data region; the engine
|
||||
reads `{offset, length}` from the index struct and slices the data
|
||||
region.
|
||||
|
||||
### 3. Validation
|
||||
|
||||
Delegated to `jsonschema` custom keywords. Each `TypeDef:*` kind gets a
|
||||
`Keyword` implementation:
|
||||
|
||||
```rust
|
||||
struct Float32Validator;
|
||||
|
||||
impl Keyword for Float32Validator {
|
||||
fn validate<'i>(&self, instance: &'i Value) -> Result<(), ValidationError<'i>> {
|
||||
match instance {
|
||||
Value::Number(n) if n.as_f64().map_or(false, |f| f.is_finite()) => Ok(()),
|
||||
_ => Err(ValidationError::custom("expected finite f32-compatible number")),
|
||||
}
|
||||
}
|
||||
fn is_valid(&self, instance: &Value) -> bool {
|
||||
instance.as_f64().map_or(false, |f| f.is_finite())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The validators check:
|
||||
- Range constraints for ints (Int8: -128..127, Uint8: 0..255, etc.)
|
||||
- Finiteness for floats
|
||||
- UTF-8 validity for strings
|
||||
- Field presence and types for structs
|
||||
- Discriminator value membership for unions
|
||||
- Element type conformance for arrays
|
||||
|
||||
The `jsonschema` crate handles all the structural validation (object
|
||||
properties, required fields, array items, enum values) — the custom
|
||||
keywords only need to validate the leaf type constraints.
|
||||
|
||||
---
|
||||
|
||||
## The Hard Problems
|
||||
|
||||
### Problem 1: Variable-length types in fixed-offset layouts
|
||||
|
||||
Variable-length types (`TString`, `TBytes`, `TArray`, `TRecord`) don't
|
||||
have a fixed byte size. Two strategies are needed:
|
||||
|
||||
**Strategy A: Inline length-prefixing (default).** The field's fixed
|
||||
portion is a 4-byte length prefix at a computed offset. The variable
|
||||
data follows immediately after. This is the universal pattern used by
|
||||
channels (`[channel_id: u32][size: u32][payload]`), SFTP (all strings
|
||||
and byte arrays), TTY (`[stream_type: u8][length: u32][payload]`), and
|
||||
most binary protocols.
|
||||
|
||||
Schema annotation:
|
||||
```json
|
||||
{
|
||||
"TypeDef:String": true,
|
||||
"encoding": "length-prefixed"
|
||||
}
|
||||
```
|
||||
|
||||
The offset map records the position of the 4-byte length prefix. At
|
||||
read time, the engine reads the length, then slices the data that
|
||||
follows. At write time, the engine writes the length prefix + data.
|
||||
|
||||
**Strategy B: Offset indirection (opt-in).** The field is a struct
|
||||
`{offset: u32, length: u32}` that points into a separate data region.
|
||||
This is the metatensor blob tensor pattern — the index struct lives in
|
||||
one region, the blob data lives in another. Enables mmap-friendly
|
||||
random access to variable-length data without parsing length prefixes.
|
||||
|
||||
Schema annotation:
|
||||
```json
|
||||
{
|
||||
"TypeDef:String": true,
|
||||
"encoding": "offset-indirect"
|
||||
}
|
||||
```
|
||||
|
||||
The offset map records the position of the `{offset, length}` pair. The
|
||||
consumer provides the data region separately; the engine slices
|
||||
`data_region[offset..offset+length]`.
|
||||
|
||||
**Default:** Inline length-prefixing is the default for all
|
||||
variable-length types. Offset indirection is opt-in via the `encoding`
|
||||
annotation. This matches the volume: most binary protocols use inline
|
||||
length-prefixing; metatensor is the oddball that needs indirection.
|
||||
|
||||
**The channels protocol example.** The channels wire format is
|
||||
`[channel_id: u32][size: u32][payload]`. Under typedef, this is a
|
||||
struct with three fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"channel_id": { "TypeDef:Uint32": true },
|
||||
"size": { "TypeDef:Uint32": true },
|
||||
"payload": { "TypeDef:Bytes": true, "encoding": "length-prefixed" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The first two fields are fixed-size (4 bytes each, offsets 0 and 4).
|
||||
The third field's *length prefix* is at offset 8 (4 bytes), and the
|
||||
payload data follows at offset 12. The `size` field and the payload's
|
||||
length prefix are redundant — the channels layer writes both, but the
|
||||
typedef engine only reads the length prefix. This is a small amount of
|
||||
waste per frame, but the trade-off is that the schema is
|
||||
self-describing: the engine doesn't need to know that `size` and
|
||||
`payload.length` are the same value.
|
||||
|
||||
### Problem 2: TUnion discriminator encoding
|
||||
|
||||
`typedef.ts`'s `TUnion` has a `discriminator` field name (like
|
||||
`"type"`) and a `mapping` of string values to structs. But in binary
|
||||
protocols, the discriminator is typically a type *byte* at a fixed
|
||||
position (byte 0), not a named field. Two discriminator kinds are
|
||||
needed:
|
||||
|
||||
**Kind A: Byte-offset discriminator (binary protocols).** The
|
||||
discriminator is a fixed-size integer at a known byte offset. The
|
||||
mapping is integer value → variant struct.
|
||||
|
||||
```json
|
||||
{
|
||||
"TypeDef:Union": true,
|
||||
"discriminator": {
|
||||
"kind": "byte",
|
||||
"offset": 0,
|
||||
"type": "TypeDef:Uint8"
|
||||
},
|
||||
"mapping": {
|
||||
"1": { "$ref": "#/$defs/Init" },
|
||||
"3": { "$ref": "#/$defs/Open" },
|
||||
"5": { "$ref": "#/$defs/Read" },
|
||||
"6": { "$ref": "#/$defs/Write" },
|
||||
"101": { "$ref": "#/$defs/Status" },
|
||||
"102": { "$ref": "#/$defs/Handle" },
|
||||
"103": { "$ref": "#/$defs/Data" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This is the SFTP `Packet` enum pattern — byte 0 is the type byte, the
|
||||
payload (bytes 1..N) is the variant struct. The call protocol's 5 event
|
||||
types (`call.requested` → 0x01, `call.responded` → 0x02, etc.) use the
|
||||
same pattern.
|
||||
|
||||
**Kind B: Field-name discriminator (typedef.ts pattern).** The
|
||||
discriminator is a named field within the struct, and the mapping is
|
||||
string value → variant struct.
|
||||
|
||||
```json
|
||||
{
|
||||
"TypeDef:Union": true,
|
||||
"discriminator": {
|
||||
"kind": "field",
|
||||
"name": "type"
|
||||
},
|
||||
"mapping": {
|
||||
"0": { "$ref": "#/$defs/Read" },
|
||||
"1": { "$ref": "#/$defs/Write" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This is the typedef.ts `TUnion` pattern — the discriminator is a field
|
||||
like any other, and the mapping keys are string indices.
|
||||
|
||||
**The discriminator type determines the offset computation.** For
|
||||
byte-offset discriminators, the discriminator occupies `offset..offset
|
||||
+ discriminator_size` bytes, and the variant struct starts at `offset +
|
||||
discriminator_size`. For field-name discriminators, the discriminator
|
||||
is just another field in the struct — its offset is computed like any
|
||||
other field, and the variant struct follows at the end of the
|
||||
discriminator field.
|
||||
|
||||
### Problem 3: Nested structs and arrays of structs
|
||||
|
||||
**Nested structs** (`TStruct` containing `TStruct`): recursive offset
|
||||
computation. The outer struct's field offset is the inner struct's
|
||||
start position; the inner struct's fields are computed relative to that
|
||||
start. Straightforward for fixed-size inner structs.
|
||||
|
||||
**Arrays of structs** (`TArray` of `TStruct`): element stride = struct
|
||||
size. For fixed-size structs, the stride is the struct's total size
|
||||
(including alignment padding). Element `i` starts at `array_offset + i
|
||||
* stride`. Straightforward.
|
||||
|
||||
**The hard case: arrays of structs containing variable-length fields.**
|
||||
If each element has a `TString` (length-prefixed), the string data for
|
||||
element 0 follows the fixed fields of element 0, then element 1's
|
||||
fixed fields follow, then element 1's string data, etc. The elements
|
||||
are interleaved: `[fixed_0][str_0][fixed_1][str_1]...`. The engine
|
||||
can't compute a fixed stride — it must walk the array sequentially,
|
||||
reading each element's length prefixes to find the next element's
|
||||
start.
|
||||
|
||||
This is the general case of Problem 1. The resolution: for arrays of
|
||||
structs with variable-length fields, the engine computes offsets
|
||||
lazily — it reads each element's fixed fields, uses the length prefixes
|
||||
to skip the variable data, and advances to the next element. The
|
||||
`OffsetMap` records the array's start offset and element count; the
|
||||
read function walks the array on demand.
|
||||
|
||||
**For v1, arrays of fixed-size structs are fully supported. Arrays of
|
||||
variable-length-element structs are deferred** — they require the lazy
|
||||
walking logic and don't appear in the initial consumers (SFTP's `Name`
|
||||
packet has `Vec<File>` where `File` contains strings, but SFTP
|
||||
serializes the whole array with length prefixes per field, not per
|
||||
element — the serde `SeqAccess` pattern handles this).
|
||||
|
||||
### Problem 4: Alignment
|
||||
|
||||
Different backends have different alignment requirements:
|
||||
- wgpu: 256-byte alignment for some buffer types
|
||||
- Protocols: natural alignment (4-byte for u32, 8-byte for u64)
|
||||
- mmap: page alignment (4096-byte)
|
||||
|
||||
**Resolution: schema-annotated with sensible defaults.** Each type has
|
||||
a default alignment (1 for u8, 2 for u16, 4 for u32/f32, 8 for u64/f64,
|
||||
max field alignment for structs). The schema can override:
|
||||
|
||||
```json
|
||||
{
|
||||
"TypeDef:Struct": true,
|
||||
"align": 256,
|
||||
"properties": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
The offset computation respects the annotation. Consumers can also
|
||||
provide a global alignment override (e.g., "round all structs to
|
||||
256-byte boundaries for wgpu"). The engine computes natural offsets;
|
||||
the consumer applies the override as a post-processing step.
|
||||
|
||||
**For v1, natural alignment with schema-level overrides is sufficient.**
|
||||
Global alignment overrides are a consumer concern, not an engine
|
||||
concern.
|
||||
|
||||
---
|
||||
|
||||
## What This Is Not (Scope Boundaries)
|
||||
|
||||
### Not metatensor
|
||||
|
||||
`alknet-typedef` is the binary struct *engine*. Metatensor is a
|
||||
*format* (8-byte header length + JSON header + binary data) that uses
|
||||
the typedef engine for its offset computation and tensor access. The
|
||||
metatensor format also includes: the 8-byte header, the JSON header
|
||||
parsing, the mmap integration, the QUIC stream mapping, the ujsx
|
||||
authoring layer, and the wgpu buffer bridge. typedef is the engine;
|
||||
metatensor builds on it.
|
||||
|
||||
### Not a Value system
|
||||
|
||||
TypeBox's `Value.Diff`, `Value.Migrate`, `Value.Convert` — schema
|
||||
evolution — is out of scope for v1. The typedef engine reads and
|
||||
writes bytes at computed offsets; it does not diff schemas or migrate
|
||||
data between schema versions. Schema evolution is a higher-level
|
||||
concern (metatensor format versioning, call protocol versioning). The
|
||||
engine should not do anything that explicitly blocks adding a Value
|
||||
system later (e.g., don't bake in assumptions about schema
|
||||
immutability).
|
||||
|
||||
### Not a code generator
|
||||
|
||||
typebox-rs's `codegen/` module (Rust/TypeScript/WGSL code generation
|
||||
via Handlebars) is a separate concern. The typedef engine consumes
|
||||
schemas; it does not generate them. Schemas are authored in TypeBox
|
||||
(JS) or hand-written JSON.
|
||||
|
||||
### Not a schema builder
|
||||
|
||||
The typedef engine does not provide a fluent API for constructing
|
||||
schemas. Schemas are plain JSON — authored in TypeBox, generated by
|
||||
ujsx components, or hand-written. The engine only consumes them.
|
||||
|
||||
### Not a serialization framework
|
||||
|
||||
The typedef engine is not a general-purpose serde replacement. It
|
||||
operates on raw byte buffers at computed offsets — no intermediate
|
||||
`Value` tree, no reflection, no dynamic dispatch per field. It is
|
||||
closer to `#[repr(C)]` struct field access than to serde. For JSON data,
|
||||
use serde. For binary data with a known schema, use typedef.
|
||||
|
||||
---
|
||||
|
||||
## Consumers
|
||||
|
||||
| Consumer | Schema describes | Engine provides |
|
||||
|----------|-----------------|-----------------|
|
||||
| russh-sftp | 29 packet structs + Packet union (byte discriminator) | Read/write SFTP frames from bytes |
|
||||
| metatensor | Model layout (ConvNet struct, tensor refs) | Offset map for mmap'd tensor access |
|
||||
| binary call frames | `call.requested` / `call.responded` / etc. structs | Read/write binary call frames |
|
||||
| TTY negotiation | `NegotiateRequest` / `NegotiateResponse` structs | Read/write TTY control frames |
|
||||
| channels wire | `ChunkHeader { channel_id, length }` | Already trivial (8 bytes, no schema needed) |
|
||||
|
||||
The russh-sftp case is the most instructive and the highest-value POC
|
||||
target. The `Packet` enum's `TryFrom<&mut Bytes>` impl is a
|
||||
hand-written dispatch on a type byte followed by serde
|
||||
deserialization. Under typedef, the dispatch is `TUnion` with a
|
||||
byte-offset discriminator — the schema says "byte 0 is the
|
||||
discriminator, bytes 1..N are the variant struct." The engine reads the
|
||||
discriminator, looks up the variant schema, computes offsets, reads
|
||||
fields. Same result, no per-packet-type code.
|
||||
|
||||
### Relationship to the call protocol
|
||||
|
||||
The call protocol's `OperationSpec.input_schema` and
|
||||
`OperationSpec.output_schema` are already JSON Schemas. With typedef,
|
||||
those schemas can describe binary payloads — not just JSON validation
|
||||
shapes. An op with `channel_open: Some(...)` and a typedef schema for
|
||||
its input/output is a binary-stream op whose wire format is
|
||||
schema-driven. The handler doesn't write a serde struct; it writes
|
||||
bytes at schema-computed offsets. The schema is the format.
|
||||
|
||||
This closes the loop on "channels is call with a binary data plane"
|
||||
from the call-channels-unification findings: the binary data plane's
|
||||
wire format is the call protocol's own schema system, just
|
||||
binary-encoded. The `channel_open` marker says "use binary framing";
|
||||
the typedef engine says "here's how to read/write the binary payload."
|
||||
|
||||
---
|
||||
|
||||
## POC Results
|
||||
|
||||
POCs live in the global workspace (`/workspace/alknet-typedef-poc/`),
|
||||
not in the alknet repo. The research doc references them; the code is
|
||||
disposable.
|
||||
|
||||
### POC 1: Core offset computation — COMPLETE
|
||||
|
||||
**Target:** `/workspace/alknet-typedef-poc/`
|
||||
|
||||
**What was built:**
|
||||
- `OffsetMap` with natural alignment padding for fixed-size fields
|
||||
- Read/write for u8, u16, u32, u64, f32 at computed offsets
|
||||
- Read/write for length-prefixed strings and byte arrays
|
||||
- Nested struct support with dotted field paths (`header.version`)
|
||||
- Endianness support (little-endian default, big-endian via `"endian":
|
||||
"big"`)
|
||||
- jsonschema custom keyword validators for all 16 `TypeDef:*` kinds
|
||||
(Float32, Float64, Int8/16/32, Uint8/16/32, Boolean, String, Struct,
|
||||
Array, Enum, Union, Record, Timestamp)
|
||||
- `TypedefEngine` struct combining offset map + validator
|
||||
|
||||
**Key findings:**
|
||||
- `serde_json` needs the `preserve_order` feature — field order matters
|
||||
for binary layouts
|
||||
- Nested struct handling must happen inline in `compute_struct_offsets`
|
||||
to propagate the field path prefix correctly
|
||||
- The `jsonschema` crate's `with_keyword` API works cleanly — each
|
||||
factory is ~5 lines
|
||||
- Alignment padding between fields (e.g., u8 → u32 inserts 3 bytes) is
|
||||
correct for mmap-friendly layouts but wrong for protocol wire formats
|
||||
|
||||
**Code size:** ~1,268 lines (lib + offset + validate), ~174 lines of
|
||||
integration tests. 16 unit tests + 3 integration tests, all passing.
|
||||
|
||||
### POC 2: russh-sftp packet round-trip — COMPLETE
|
||||
|
||||
**What was built:**
|
||||
- 3 SFTP packet types as typedef schemas: `Read` (id: u32, handle:
|
||||
string, offset: u64, len: u32), `Write` (id: u32, handle: string,
|
||||
offset: u64, data: bytes), `Status` (id: u32, status_code: u32,
|
||||
error_message: string, language_tag: string)
|
||||
- `Packet` union schema with byte-offset discriminator (byte 0, u8,
|
||||
big-endian)
|
||||
- `LayoutBuilder` — packed sequential layout for protocol wire formats
|
||||
(no alignment padding, variable-length fields shift subsequent fields)
|
||||
- `SequentialReader` — walks a buffer field-by-field according to the
|
||||
schema, reading length prefixes to determine variable-length data
|
||||
positions
|
||||
- `parse_union_discriminator` + `read_union_discriminator` for TUnion
|
||||
dispatch
|
||||
- Byte-identical round-trip tests against russh-sftp's own serialization
|
||||
|
||||
**Key findings:**
|
||||
- **Two layout modes are required.** The aligned `OffsetMap` from POC 1
|
||||
is correct for mmap-friendly formats (metatensor) but wrong for
|
||||
protocol wire formats (SFTP, channels, TTY). Protocols pack fields
|
||||
sequentially with no alignment padding. This is the most important
|
||||
architectural finding from the POCs.
|
||||
- **Variable-length fields shift all subsequent fields in protocol
|
||||
layouts.** The `LayoutBuilder` takes actual data sizes to compute
|
||||
correct positions. The `SequentialReader` walks the buffer
|
||||
sequentially, reading length prefixes to find each field's data.
|
||||
- **TUnion with byte-offset discriminator works correctly.** The SFTP
|
||||
type byte (5=Read, 6=Write, 101=Status) dispatches to the correct
|
||||
variant schema. The mapping keys are stringified integers (`"5"`,
|
||||
`"6"`, `"101"`).
|
||||
- **Endianness is per-schema.** SFTP uses `"endian": "big"`; the engine
|
||||
reads/writes correctly in both little and big endian.
|
||||
- **The `jsonschema` crate's `serde_json::Value`-based validation works
|
||||
for protocol schemas.** The same schema validates both the JSON
|
||||
representation and (via the typedef engine) the binary representation.
|
||||
|
||||
**Code size:** ~347 lines of integration tests. 7 tests, all passing
|
||||
(5 round-trip against russh-sftp + 1 union dispatch + 1 schema
|
||||
validation).
|
||||
|
||||
### POC 3: Metatensor header parse + mmap access — NOT STARTED
|
||||
|
||||
Deferred. The aligned `OffsetMap` from POC 1 is the correct engine for
|
||||
this; the remaining work is the metatensor format layer (8-byte header,
|
||||
JSON header parsing, mmap integration).
|
||||
|
||||
### POC 4: WASM compilation — NOT STARTED
|
||||
|
||||
Deferred. The `jsonschema` crate's docs confirm WASM support with
|
||||
`default-features = false`. The typedef engine's core (offset
|
||||
computation, read/write) operates on `&[u8]` slices with no platform
|
||||
dependencies.
|
||||
|
||||
### Summary of architectural findings
|
||||
|
||||
| Finding | Impact |
|
||||
|---------|--------|
|
||||
| Two layout modes needed (packed vs aligned) | The engine must support both. `LayoutBuilder`/`SequentialReader` for protocols; `OffsetMap` for mmap formats. |
|
||||
| `serde_json` needs `preserve_order` | Field order is load-bearing for binary layouts. |
|
||||
| jsonschema custom keywords work cleanly | 16 `TypeDef:*` kinds registered, all passing. No need for a custom schema engine. |
|
||||
| TUnion byte-offset discriminator works | SFTP type byte dispatch confirmed. Same pattern works for call protocol event types. |
|
||||
| Endianness is per-schema | `"endian": "big"` / `"endian": "little"` annotation. Default little-endian. |
|
||||
| Variable-length fields shift subsequent fields in protocol layouts | `LayoutBuilder` takes actual sizes; `SequentialReader` walks sequentially. |
|
||||
| Nested structs work with dotted field paths | `header.version` → recursive offset computation with prefix propagation. |
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
### 1. Variable-length encoding annotation shape
|
||||
|
||||
The `"encoding": "length-prefixed"` vs `"encoding": "offset-indirect"`
|
||||
annotation needs a concrete JSON shape. Options:
|
||||
- A string enum on the type keyword: `"TypeDef:String": { "encoding":
|
||||
"length-prefixed" }` (requires the keyword value to be an object, not
|
||||
`true`)
|
||||
- A separate keyword: `"TypeDef:String": true, "typedef:encoding":
|
||||
"length-prefixed"`
|
||||
- A default with override: length-prefixed is the default; only
|
||||
offset-indirect needs explicit annotation
|
||||
|
||||
**Recommendation:** The keyword value is an object with an `encoding`
|
||||
field. `"TypeDef:String": { "encoding": "length-prefixed" }` or
|
||||
`"TypeDef:String": { "encoding": "offset-indirect" }`. `true` is a
|
||||
shorthand for the default (length-prefixed). This keeps the common case
|
||||
concise and the override explicit.
|
||||
|
||||
### 2. TUnion discriminator JSON shape
|
||||
|
||||
The `"discriminator"` field needs a concrete shape that covers both
|
||||
byte-offset and field-name variants. Proposed:
|
||||
|
||||
```json
|
||||
// Byte-offset discriminator
|
||||
"discriminator": {
|
||||
"kind": "byte",
|
||||
"offset": 0,
|
||||
"type": "TypeDef:Uint8"
|
||||
}
|
||||
|
||||
// Field-name discriminator
|
||||
"discriminator": {
|
||||
"kind": "field",
|
||||
"name": "type"
|
||||
}
|
||||
```
|
||||
|
||||
The `mapping` keys are strings in both cases (integer values are
|
||||
stringified: `"1"`, `"3"`, `"101"`). The engine parses the key to
|
||||
match the discriminator value.
|
||||
|
||||
**Open sub-question:** Should the mapping values be full schemas or
|
||||
`$ref` pointers? `$ref` is cleaner for large unions (29 SFTP variants)
|
||||
but requires a `$defs` section. Inline schemas are simpler for small
|
||||
unions (5 call protocol event types). Both should work.
|
||||
|
||||
### 3. Alignment annotation shape
|
||||
|
||||
The `"align"` annotation needs a concrete shape. Options:
|
||||
- A top-level schema property: `{ "TypeDef:Struct": true, "align": 256,
|
||||
"properties": {...} }`
|
||||
- A per-field property: `{ "weight": { "TypeDef:Float32": true, "align":
|
||||
256 } }`
|
||||
- Both (field overrides struct default)
|
||||
|
||||
**Recommendation:** Both. Struct-level `align` sets the default for all
|
||||
fields; field-level `align` overrides. The engine applies the most
|
||||
specific annotation.
|
||||
|
||||
### 4. Endianness
|
||||
|
||||
safetensors is little-endian. SFTP is big-endian. The typedef engine
|
||||
needs to know which to use. Options:
|
||||
- Schema-level annotation: `{ "endian": "little" }` or `{ "endian":
|
||||
"big" }`
|
||||
- Per-type annotation (overkill — mixed endianness in one schema is
|
||||
pathological)
|
||||
- Consumer-level configuration (the engine is endian-agnostic; the
|
||||
consumer specifies)
|
||||
|
||||
**Recommendation:** Schema-level annotation with a default of
|
||||
little-endian (matching safetensors and wgpu). The SFTP consumer
|
||||
specifies `"endian": "big"`. The engine reads the annotation and
|
||||
byte-swaps accordingly.
|
||||
|
||||
### 5. Arrays of variable-length-element structs
|
||||
|
||||
Deferred for v1. The SFTP `Name` packet has `Vec<File>` where `File`
|
||||
contains strings — but SFTP serializes this as a sequence of
|
||||
length-prefixed strings (the serde `SeqAccess` pattern), not as an
|
||||
array of fixed-stride structs. The typedef engine can handle this by
|
||||
treating the array as a sequence of length-prefixed elements rather
|
||||
than a fixed-stride array. The exact mechanism (schema annotation for
|
||||
"this array has variable-length elements, walk sequentially") is a v2
|
||||
concern.
|
||||
|
||||
### 6. `no_std` compatibility
|
||||
|
||||
The `jsonschema` crate requires `alloc` (for `String`, `Vec`,
|
||||
`HashMap`) but not `std`. The typedef engine's offset computation and
|
||||
read/write functions operate on `&[u8]` slices — no allocation needed.
|
||||
The question is whether to target `no_std` + `alloc` or just `std`.
|
||||
For WASM, `std` is available via `wasm-bindgen`. For embedded, `no_std`
|
||||
+ `alloc` would be needed.
|
||||
|
||||
**Recommendation:** Target `std` for v1. The WASM target has `std`
|
||||
available. If embedded use cases emerge, `no_std` + `alloc` can be
|
||||
added as a feature gate later — the engine's core (offset computation,
|
||||
read/write) is already allocation-free.
|
||||
|
||||
### 7. Schema validation at load time vs access time
|
||||
|
||||
The jsonschema validator is built once at schema load time
|
||||
(`validator_for(&schema)?`) and then called repeatedly
|
||||
(`validator.is_valid(&instance)`). The typedef engine should follow the
|
||||
same pattern: parse the schema once, build the offset map and validator
|
||||
once, then use them for repeated read/write/validate operations. The
|
||||
`TypedefEngine` struct is the compiled form of a schema.
|
||||
|
||||
### 8. Error handling strategy
|
||||
|
||||
The engine needs clear error types for:
|
||||
- Schema parsing errors (invalid JSON, missing required keywords,
|
||||
unknown TypeDef kinds)
|
||||
- Offset computation errors (field not found, type not supported for
|
||||
offset computation)
|
||||
- Read/write errors (buffer too short, invalid UTF-8, value out of
|
||||
range)
|
||||
- Validation errors (delegated to jsonschema's `ValidationError`)
|
||||
|
||||
**Recommendation:** A single `TypedefError` enum with variants for each
|
||||
category. The jsonschema `ValidationError` is wrapped as
|
||||
`TypedefError::Validation(ValidationError)`. Read/write errors carry
|
||||
the field path for debugging.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- **typedef.ts (binary layout types):** `/workspace/@alkdev/typebox/example/typedef/typedef.ts`
|
||||
(619 lines) — `TFloat32`, `TInt32`, `TStruct`, `TUnion`, `TEnum` with
|
||||
`TypeRegistry.Set<...>` custom validators
|
||||
- **typebox-rs (prior attempt, to be replaced):** `/workspace/@alkimiadev/typebox-rs/`
|
||||
(~8,400 lines) — full TypeBox port with its own jsonschema engine;
|
||||
`layout.rs` (193 lines) is the salvageable offset computation
|
||||
- **alktype (prior attempt, to be replaced):** `/workspace/@alkimiadev/alktype/`
|
||||
(~5,600 lines) — handler-registry approach, also built its own
|
||||
validation; only intrinsics implemented
|
||||
- **jsonschema crate (Rust validation):** `/workspace/jsonschema/`
|
||||
(v0.46.5, Draft 2020-12 default) — `validator_for(&schema)?`,
|
||||
`with_keyword("TypeDef:...", factory)` for custom kinds,
|
||||
`with_format("...", validator)` for custom formats
|
||||
- **russh-sftp protocol packets:** `/workspace/russh-sftp/src/protocol/`
|
||||
— 29 packet structs, `Packet` enum with u8 type byte dispatch, custom
|
||||
serde Serializer/Deserializer; the primary POC 2 target
|
||||
- **metatensor format:** `docs/research/alknet-tensor/metatensor-format.md`
|
||||
— the format that builds on typedef; 8-byte header + JSON header +
|
||||
binary data; flat/struct/blob tensor kinds
|
||||
- **call-channels-unification findings:** `docs/research/call-channels-unification/findings.md`
|
||||
§"alknet-typedef: JSON Schema as the binary struct engine" — the
|
||||
origin of this research thread; the `channel_open` marker + typedef
|
||||
engine = schema-driven binary data plane
|
||||
- **stream-unification findings:** `docs/research/stream-unification/findings.md`
|
||||
— the research-then-sync pattern precedent; channels as pure channel
|
||||
multiplexing (8-byte header, handler-owns-sub-multiplexing)
|
||||
Reference in new issue
Block a user