From dabc89e1e310f4a599ba9a533b8edd6d019641b4 Mon Sep 17 00:00:00 2001 From: Suhas Jayaram Subramanya Date: Tue, 8 Sep 2026 15:48:41 -0700 Subject: [PATCH 1/2] added initial RFC --- rfcs/01386-runtime-parameters-for-load.md | 176 ++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 rfcs/01386-runtime-parameters-for-load.md diff --git a/rfcs/01386-runtime-parameters-for-load.md b/rfcs/01386-runtime-parameters-for-load.md new file mode 100644 index 0000000000..fd642bdddc --- /dev/null +++ b/rfcs/01386-runtime-parameters-for-load.md @@ -0,0 +1,176 @@ +# Runtime Parameters for Load + +| | | +|---|---| +| **Authors** | Suhas Jayaram Subramanya (suhasjs) | +| **Contributors** | | +| **Created** | 2026-09-08 | +| **Updated** | 2026-09-08 | + +## Summary + +Extend `diskann-record` so each target type declares one runtime-parameter contract for reconstruction. Loading remains type-directed: callers choose the target Rust type, while persisted schema information governs decoding, including legacy upgrades and probing. Runtime parameters only control process-local construction choices such as capacity, concurrency limits, and scratch allocation. + +## Motivation + +A saved in-memory index contains only its live nodes. Loading 100,000 saved nodes may construct an index with capacity for 200,000, one million, or ten million nodes. Capacity is not persisted because it describes the new runtime allocation rather than the logical index. + +The existing `Load` API receives only persisted data. Extend it rather than introducing a parallel `LoadWith` interface that permits multiple runtime contracts for the same target type. A generic loading trait does not inherently prevent self-describing persistence; the goal here is one explicit runtime contract within the existing loading interface. + +## Proposal + +Every loadable type declares one runtime-parameter type. The following examples sketch the proposed API, not the current implementation. + +```rust +trait Load<'a>: Sized { + type Runtime: ?Sized; + const VERSION: Version; + + fn load(object: Object<'a>, runtime: &Self::Runtime) -> Result; + fn load_legacy(object: Object<'a>, runtime: &Self::Runtime) -> Result; +} + +trait Loadable<'a>: Sized { + type Runtime: ?Sized; + + fn load(context: Context<'a>, runtime: &Self::Runtime) -> Result; +} +``` + +The blanket `Loadable` implementation for `T: Load` sets `Runtime = >::Runtime`. It retains the existing object-shape check and version dispatch, forwarding the same runtime argument to either `load` or `load_legacy`. Existing legacy upgrades and recoverable probing remain supported. Implementations must use persisted schema information, not runtime parameters, to interpret field encodings and choose decoding paths. This is an implementation contract, not a restriction enforced by the associated type. + +### One Calling Convention + +Use the same explicit runtime argument throughout. `()` means no runtime input; it is not an implicit default. Rather than adding `field_runtime`, change `Object::field` and `Context::load`: + +```rust +impl<'a> Context<'a> { + fn load>(&self, runtime: &T::Runtime) -> Result { + >::load(self.clone(), runtime) + } +} + +impl<'a> Object<'a> { + fn field>(&self, key: &str, runtime: &T::Runtime) -> Result { + self.child(key)?.load::(runtime) + } +} +``` + +These helpers go through `Loadable`, preserving version dispatch. Runtime input is not stored in `Context`, `Object`, or the backend. A parent explicitly supplies each child's parameters; the return type or a type annotation identifies the child type, not the runtime argument alone. + +**NOTE**: The runtime argument is borrowed for the call, independently of the manifest lifetime. Loaders may copy configuration or clone shared resources such as `Arc`s; retaining borrowed resources requires their lifetimes to be represented explicitly in the result and runtime types. + +The bootstrap implementations must also change: + +| Implementation | Runtime and forwarding | +|---|---| +| Numerics, nonzero numerics, `bool`, `String`, `&str`, `Handle` | Declare `Runtime = ()`; retain existing decoding and validation. | +| `Option` | Declare `Runtime = T::Runtime`; forward it to a present value. | +| `Vec` | Declare `Runtime = T::Runtime`; pass the same argument to every element. | + +Both numeric macros generate the unit-runtime signature. Internal primitive loads pass `&()`. Collections requiring different parameters per element use explicit array iteration. + +**`load_fields!` extension**: Extend `load_fields!` with an optional `=> runtime_expression` after each field's optional type annotation. The expression supplies the runtime reference directly; omitting it supplies `&()`, requiring that field's `Runtime` to be `()`. This is proposed macro syntax, not functionality in the current implementation. + +For example, `graph: Graph => &runtime.graph` expands to `let graph: Graph = object.field("graph", &runtime.graph)?;`, while `metadata: Metadata` expands to `let metadata: Metadata = object.field("metadata", &())?;`. Type annotations remain optional when the surrounding code determines the target type. The macro only abbreviates field calls; it does not infer or inherit runtime parameters. + +## Example with a nested runtime type + +This small example uses inline adjacency lists for clarity; a real index streams them through sidecar artifacts. + +```rust +struct Metadata { + name: String, // Saved. +} + +struct Graph { + adjacency: Vec>, // Saved, one row per live node. + max_slots: usize, // Runtime allocation limit; not saved. +} + +struct Index { + metadata: Metadata, + graph: Graph, + scratch: Vec, // Runtime scratch; not saved. +} + +struct GraphRuntime { + max_slots: usize, +} + +struct IndexRuntime { + graph: GraphRuntime, + scratch_capacity: usize, +} +``` + +The nested types own their persistence implementations: + +| Type | `Save` fields | `Load::Runtime` | Construction | +|---|---|---|---| +| `Metadata` | `name` | `()` | Decode the name. | +| `Graph` | `adjacency` | `GraphRuntime` | Validate edges and `max_slots >= adjacency.len()`, then allocate the requested slot capacity. | + +Both use schema version `0.0` and reject unsupported versions. Invalid capacity is a critical load error, not a reason to try another schema. Neither type serializes its runtime parameters. + +`Index` implements `Save` and `Load`, but not `Loadable` directly: + +```rust +impl save::Save for Index { + const VERSION: Version = Version::new(0, 0); + + fn save(&self, context: save::Context<'_>) -> save::Result> { + Ok(save_fields!(self, context, [metadata, graph])) + } +} + +impl load::Load<'_> for Index { + type Runtime = IndexRuntime; + const VERSION: Version = Version::new(0, 0); + + fn load(object: load::Object<'_>, runtime: &IndexRuntime) -> load::Result { + load_fields!(object, [ + metadata: Metadata, + graph: Graph => &runtime.graph, + ]); + let mut scratch = Vec::new(); + scratch.try_reserve_exact(runtime.scratch_capacity).map_err(load::Error::new)?; + Ok(Self { metadata, graph, scratch }) + } + + fn load_legacy(_: load::Object<'_>, _: &IndexRuntime) -> load::Result { + Err(load::error::Kind::UnknownVersion.into()) + } +} +``` + +`Metadata` receives no runtime input. `Graph` receives only its allocation parameters, not the parent's scratch configuration. Saving the result again writes only metadata and live adjacency rows, regardless of the chosen capacity. + +### Backend Entry Points + +Disk and memory loads pass the runtime argument to the same dispatcher. Backend implementations remain responsible only for the manifest and artifacts; no persisted format changes are needed. + +The context-based loader retains `T: Loadable<'a>` for a caller-owned context borrowed for `'a`, so results may continue to borrow from that context. The disk entry point instead creates a temporary context and requires a result that cannot borrow from it: + +```rust +fn load_from_disk( + metadata: &Path, + artifacts: &Path, + runtime: &R, +) -> Result +where + T: for<'a> Loadable<'a, Runtime = R>; +``` +and it's called like this: +```rust +let runtime = IndexRuntime { + graph: GraphRuntime { max_slots: 200_000 }, + scratch_capacity: 64, +}; +let index: Index = load_from_disk(&metadata_path, &artifact_dir, &runtime)?; +``` + +The equality bound fixes the associated runtime type across manifest lifetimes; `R` does not select an alternative implementation of `Load`. A runtime-independent root uses the same entry point with `&()`. + +Memory context types are public, but generic save/load execution is currently crate-private. This proposal updates runtime forwarding through that path; exposing public memory execution APIs is outside its scope. \ No newline at end of file From 1c6d23035e8e300732cbbcc527e54a3a86fed543 Mon Sep 17 00:00:00 2001 From: Suhas Jayaram Subramanya Date: Wed, 9 Sep 2026 15:49:28 -0700 Subject: [PATCH 2/2] updated pattern -- no longer need a Runtime for each everything, just the ones that need it --- rfcs/01386-runtime-parameters-for-load.md | 182 ++++++++++++---------- 1 file changed, 97 insertions(+), 85 deletions(-) diff --git a/rfcs/01386-runtime-parameters-for-load.md b/rfcs/01386-runtime-parameters-for-load.md index fd642bdddc..956991596c 100644 --- a/rfcs/01386-runtime-parameters-for-load.md +++ b/rfcs/01386-runtime-parameters-for-load.md @@ -5,79 +5,79 @@ | **Authors** | Suhas Jayaram Subramanya (suhasjs) | | **Contributors** | | | **Created** | 2026-09-08 | -| **Updated** | 2026-09-08 | +| **Updated** | 2026-09-09 | ## Summary -Extend `diskann-record` so each target type declares one runtime-parameter contract for reconstruction. Loading remains type-directed: callers choose the target Rust type, while persisted schema information governs decoding, including legacy upgrades and probing. Runtime parameters only control process-local construction choices such as capacity, concurrency limits, and scratch allocation. +Add stateful loading to `diskann-record`, inspired by Serde's [`DeserializeSeed`](https://docs.rs/serde/latest/serde/de/trait.DeserializeSeed.html). A caller supplies a loader that carries construction state and declares the type it produces. Existing stateless loading remains unchanged; target types do not need an associated runtime-parameter type. ## Motivation A saved in-memory index contains only its live nodes. Loading 100,000 saved nodes may construct an index with capacity for 200,000, one million, or ten million nodes. Capacity is not persisted because it describes the new runtime allocation rather than the logical index. -The existing `Load` API receives only persisted data. Extend it rather than introducing a parallel `LoadWith` interface that permits multiple runtime contracts for the same target type. A generic loading trait does not inherently prevent self-describing persistence; the goal here is one explicit runtime contract within the existing loading interface. +The existing `Load` API receives only persisted data. A loader supplies the missing construction state: capacity settings, shared resources, owned storage, or a mutable destination. Different loaders may construct the same target type; there is no requirement for one runtime contract per target. ## Proposal -Every loadable type declares one runtime-parameter type. The following examples sketch the proposed API, not the current implementation. +Keep `Load` and `Loadable` unchanged and add the following trait. The examples sketch the proposed API, not the current implementation. ```rust -trait Load<'a>: Sized { - type Runtime: ?Sized; - const VERSION: Version; +pub trait Loader<'a>: Sized { + type Value; - fn load(object: Object<'a>, runtime: &Self::Runtime) -> Result; - fn load_legacy(object: Object<'a>, runtime: &Self::Runtime) -> Result; + fn load(self, context: Context<'a>) -> Result; } +``` + +The loader, rather than the output type, implements the operation. `Value` need not implement `Load` or `Loadable`; a loader loading into an existing destination may return `()`. Consuming the loader allows owned resources to move into the result and mutable references to be used without requiring interior mutability. -trait Loadable<'a>: Sized { - type Runtime: ?Sized; +As in Serde, `PhantomData` adapts ordinary loading to the new API: + +```rust +impl<'a, T: Loadable<'a>> Loader<'a> for std::marker::PhantomData { + type Value = T; - fn load(context: Context<'a>, runtime: &Self::Runtime) -> Result; + fn load(self, context: Context<'a>) -> Result { + >::load(context) + } } ``` -The blanket `Loadable` implementation for `T: Load` sets `Runtime = >::Runtime`. It retains the existing object-shape check and version dispatch, forwarding the same runtime argument to either `load` or `load_legacy`. Existing legacy upgrades and recoverable probing remain supported. Implementations must use persisted schema information, not runtime parameters, to interpret field encodings and choose decoding paths. This is an implementation contract, not a restriction enforced by the associated type. +Runtime state is not stored in `Context`, `Object`, or the backend and is not serialized. A loader's resource lifetimes are independent of the manifest lifetime `'a`; any resources borrowed by the output must have their lifetimes represented in the loader and output types. -### One Calling Convention +### Composition -Use the same explicit runtime argument throughout. `()` means no runtime input; it is not an implicit default. Rather than adding `field_runtime`, change `Object::field` and `Context::load`: +Add helpers that accept a loader: ```rust impl<'a> Context<'a> { - fn load>(&self, runtime: &T::Runtime) -> Result { - >::load(self.clone(), runtime) + pub fn load_with>(&self, loader: L) -> Result { + loader.load(self.clone()) } } impl<'a> Object<'a> { - fn field>(&self, key: &str, runtime: &T::Runtime) -> Result { - self.child(key)?.load::(runtime) + pub fn field_with>(&self, key: &str, loader: L) -> Result { + self.child(key)?.load_with(loader) } } ``` -These helpers go through `Loadable`, preserving version dispatch. Runtime input is not stored in `Context`, `Object`, or the backend. A parent explicitly supplies each child's parameters; the return type or a type annotation identifies the child type, not the runtime argument alone. - -**NOTE**: The runtime argument is borrowed for the call, independently of the manifest lifetime. Loaders may copy configuration or clone shared resources such as `Arc`s; retaining borrowed resources requires their lifetimes to be represented explicitly in the result and runtime types. +Existing `Context::load`, `Object::field`, `load_fields!`, and all primitive and collection implementations remain unchanged. A parent explicitly supplies loaders for stateful children, for example `object.field_with("graph", GraphLoader { max_slots })?`, and uses ordinary field loading for stateless children. -The bootstrap implementations must also change: +There is no implicit propagation into `Option` or `Vec`. Stateful collections use explicit traversal, constructing a loader for each present value or element and reborrowing shared state as needed. Loaders need not be `Clone`; generic collection adapters and macro extensions are outside this proposal. -| Implementation | Runtime and forwarding | -|---|---| -| Numerics, nonzero numerics, `bool`, `String`, `&str`, `Handle` | Declare `Runtime = ()`; retain existing decoding and validation. | -| `Option` | Declare `Runtime = T::Runtime`; forward it to a present value. | -| `Vec` | Declare `Runtime = T::Runtime`; pass the same argument to every element. | +### Schema Versions and Errors -Both numeric macros generate the unit-runtime signature. Internal primitive loads pass `&()`. Collections requiring different parameters per element use explicit array iteration. +`Loader` receives a `Context`; it does not automatically invoke the `Loadable` version dispatcher. A simple approach is to load a persisted representation through `Context::load` and then construct the runtime value. This retains the existing object-shape check, `Load::load` / `load_legacy` dispatch, and error classification. -**`load_fields!` extension**: Extend `load_fields!` with an optional `=> runtime_expression` after each field's optional type annotation. The expression supplies the runtime reference directly; omitting it supplies `&()`, requiring that field's `Runtime` to be `()`. This is proposed macro syntax, not functionality in the current implementation. +A loader may instead decode directly, including streaming sidecar artifacts, but is then responsible for the same shape and version checks and any legacy upgrades it supports. Persisted schema information, not runtime settings, determines field encodings and decoding paths. No persisted format changes are required. -For example, `graph: Graph => &runtime.graph` expands to `let graph: Graph = object.field("graph", &runtime.graph)?;`, while `metadata: Metadata` expands to `let metadata: Metadata = object.field("metadata", &())?;`. Type annotations remain optional when the surrounding code determines the target type. The macro only abbreviates field calls; it does not infer or inherit runtime parameters. +Invalid runtime settings and allocation failures are critical errors, not reasons to try another schema. Probing remains caller-controlled and retries only recoverable errors. Since loaders are consumed and may mutate external state, each attempt needs a fresh loader or reborrow; retrying also requires unchanged or restored external state. Prefer checking schema compatibility before mutating a destination. -## Example with a nested runtime type +## Example -This small example uses inline adjacency lists for clarity; a real index streams them through sidecar artifacts. +Use inline adjacency lists for this small example: ```rust struct Metadata { @@ -86,7 +86,7 @@ struct Metadata { struct Graph { adjacency: Vec>, // Saved, one row per live node. - max_slots: usize, // Runtime allocation limit; not saved. + max_slots: usize, // Runtime limit; not saved. } struct Index { @@ -94,83 +94,95 @@ struct Index { graph: Graph, scratch: Vec, // Runtime scratch; not saved. } - -struct GraphRuntime { - max_slots: usize, -} - -struct IndexRuntime { - graph: GraphRuntime, - scratch_capacity: usize, -} ``` -The nested types own their persistence implementations: - -| Type | `Save` fields | `Load::Runtime` | Construction | -|---|---|---|---| -| `Metadata` | `name` | `()` | Decode the name. | -| `Graph` | `adjacency` | `GraphRuntime` | Validate edges and `max_slots >= adjacency.len()`, then allocate the requested slot capacity. | +Assume all three already implement `Save` and `Load`; the existing blanket implementation supplies `Loadable`. Their loaders handle schema version `0.0`, reject unsupported versions, and validate saved fields and graph edges. Ordinary loading sets `Graph::max_slots` to `adjacency.len()` and initializes `Index::scratch` with `Vec::new()`. -Both use schema version `0.0` and reject unsupported versions. Invalid capacity is a critical load error, not a reason to try another schema. Neither type serializes its runtime parameters. - -`Index` implements `Save` and `Load`, but not `Loadable` directly: +`IndexLoader` loads metadata normally and supplies a `GraphLoader` for the graph field. The child receives only its slot limit; scratch allocation belongs to the parent. ```rust -impl save::Save for Index { - const VERSION: Version = Version::new(0, 0); +struct IndexLoader { + max_slots: usize, + scratch_capacity: usize, +} - fn save(&self, context: save::Context<'_>) -> save::Result> { - Ok(save_fields!(self, context, [metadata, graph])) - } +struct GraphLoader { + max_slots: usize, } -impl load::Load<'_> for Index { - type Runtime = IndexRuntime; - const VERSION: Version = Version::new(0, 0); +impl<'a> load::Loader<'a> for IndexLoader { + type Value = Index; + + fn load(self, context: load::Context<'a>) -> load::Result { + let object = context.as_object().ok_or(load::error::Kind::TypeMismatch)?; + if object.version() != >::VERSION { + return Err(load::error::Kind::UnknownVersion.into()); + } - fn load(object: load::Object<'_>, runtime: &IndexRuntime) -> load::Result { - load_fields!(object, [ - metadata: Metadata, - graph: Graph => &runtime.graph, - ]); + let metadata: Metadata = object.field("metadata")?; + let graph = object.field_with("graph", GraphLoader { max_slots: self.max_slots })?; let mut scratch = Vec::new(); - scratch.try_reserve_exact(runtime.scratch_capacity).map_err(load::Error::new)?; - Ok(Self { metadata, graph, scratch }) + scratch + .try_reserve_exact(self.scratch_capacity) + .map_err(load::Error::new)?; + + Ok(Index { metadata, graph, scratch }) } +} - fn load_legacy(_: load::Object<'_>, _: &IndexRuntime) -> load::Result { - Err(load::error::Kind::UnknownVersion.into()) +impl<'a> load::Loader<'a> for GraphLoader { + type Value = Graph; + + fn load(self, context: load::Context<'a>) -> load::Result { + let object = context.as_object().ok_or(load::error::Kind::TypeMismatch)?; + if object.version() != >::VERSION { + return Err(load::error::Kind::UnknownVersion.into()); + } + + load_fields!(object, [adjacency: Vec>]); + let mut adjacency = adjacency; + let live = adjacency.len(); + if self.max_slots < live { + return Err(load::Error::message("max_slots is smaller than the live node count")); + } + for &target in adjacency.iter().flatten() { + if usize::try_from(target).map_err(load::Error::new)? >= live { + return Err(load::Error::message("edge targets a nonexistent node")); + } + } + adjacency.try_reserve_exact(self.max_slots - live).map_err(load::Error::new)?; + + Ok(Graph { adjacency, max_slots: self.max_slots }) } } ``` -`Metadata` receives no runtime input. `Graph` receives only its allocation parameters, not the parent's scratch configuration. Saving the result again writes only metadata and live adjacency rows, regardless of the chosen capacity. +The keys `"metadata"`, `"graph"`, and `"adjacency"` match the fields written by the existing `Save` implementations. `field("metadata")` retains ordinary `Loadable` dispatch; `field_with("graph", ...)` invokes the supplied child loader. `load_fields!` derives the `"adjacency"` key from the binding name and calls `object.field("adjacency")`. Both direct loaders check their own object shape and version rather than calling `Index::load` or `Graph::load`; this example supports no legacy schemas. -### Backend Entry Points +`GraphLoader` decodes adjacency through ordinary collection loading, then validates and reserves capacity; this may reallocate. Reservation guarantees at least the requested capacity without adding live nodes, and `max_slots` remains the logical limit. Invalid capacity, invalid edges, and reservation failures are critical errors. Saving again writes only metadata and live adjacency rows, never capacity or scratch state. Production implementations should share schema checks and validation with ordinary loading. -Disk and memory loads pass the runtime argument to the same dispatcher. Backend implementations remain responsible only for the manifest and artifacts; no persisted format changes are needed. +### Backend Entry Points -The context-based loader retains `T: Loadable<'a>` for a caller-owned context borrowed for `'a`, so results may continue to borrow from that context. The disk entry point instead creates a temporary context and requires a result that cannot borrow from it: +Add a crate-private context-based `load_with` alongside the existing `load`, and a public disk convenience function: ```rust -fn load_from_disk( +pub fn load_from_disk_with( metadata: &Path, artifacts: &Path, - runtime: &R, + loader: L, ) -> Result where - T: for<'a> Loadable<'a, Runtime = R>; + L: for<'a> Loader<'a, Value = T>; ``` -and it's called like this: + +The disk function creates a temporary context and invokes the loader. Fixing `Value = T` across context lifetimes prevents the result from borrowing that temporary context; it does not require the loader or result to be `'static`, so caller-owned resources may still be borrowed. The context-based entry point uses `L: Loader<'a>` for a caller-owned context borrowed for `'a`, allowing results to borrow from it. + ```rust -let runtime = IndexRuntime { - graph: GraphRuntime { max_slots: 200_000 }, - scratch_capacity: 64, -}; -let index: Index = load_from_disk(&metadata_path, &artifact_dir, &runtime)?; +let index: Index = load_from_disk_with( + &metadata_path, + &artifact_dir, + IndexLoader { max_slots: 200_000, scratch_capacity: 64 }, +)?; ``` -The equality bound fixes the associated runtime type across manifest lifetimes; `R` does not select an alternative implementation of `Load`. A runtime-independent root uses the same entry point with `&()`. - -Memory context types are public, but generic save/load execution is currently crate-private. This proposal updates runtime forwarding through that path; exposing public memory execution APIs is outside its scope. \ No newline at end of file +Existing `load_from_disk::` remains available without an explicit loader; stateless loading can delegate through `PhantomData`. Disk and memory backends remain responsible only for manifests and artifacts. Exposing public memory execution APIs is outside this proposal. \ No newline at end of file