feat(0087): blueprint serialization & loader — close the graph-as-data loop

C24 first cut (#155): a blueprint — the param-generic, named graph-as-data a
Rust builder produces — is now a first-class serializable data value with a
canonical, versioned format, and the engine has the missing load path
(data -> blueprint -> FlatGraph), not only the Rust-builder construction path.

What ships:
- `blueprint_to_json` / `blueprint_from_json` in a new `aura-engine`
  blueprint_serde module: a faithful serde DTO projection (the build closures
  dropped; re-derived on load), top-level `format_version` envelope, canonical
  compact JSON (struct field order, defaults omitted via skip_serializing_if).
- A resolver seam: the loader is generic over an injected
  `Fn(&str) -> Option<PrimitiveBuilder>`; the concrete closed `match` over the
  aura-std vocabulary lives in aura-std (`std_vocabulary`), never in the engine
  (the engine stays domain-free: lib deps aura-core + aura-analysis only, never
  the node-vocabulary crate). This is C24's compiled-in closed-set referenced by
  type identity, NOT a dynamic/marketplace node registry (domain invariant 9).
- serde derives on the serialized plain types (Edge, Target, OutField, Role,
  BoundParam, ScalarKind); BoundParam additionally re-exported from aura-core's
  root (an additive fix the plan's file list missed).

Load-bearing scope decisions (derived, logged on #155):
- Sinks are excluded from the serialized blueprint — a recording sink captures an
  mpsc::Sender (runtime identity, not param/topology, C19 value-empty); sink
  addressing is the C18-registry / #101 concern. The round-trip test attaches
  identical sinks post-load to both twins.
- Construction-arg builders (LinComb(arity), CostSum(n), SimBroker(pip),
  Session(..)) are out of the round-trippable set: their structural args are a
  C20 structural-axis concern the param-generic format does not yet encode; an
  absent type_id fails the load cleanly (UnknownNodeType), never a silent wrong
  graph. #155's vocabulary is the 22 zero-arg aura-std builders. Additively
  extensible later (#156).

Orchestrator hardening on review: the serializer now emits bound params in
ascending original-slot order (mirroring the loader), so the canonical form is
bind-order-independent — a precondition for content-addressing (#158). Identity
today (every #155 node has <=1 param); holds by construction for future
multi-param nodes.

Acceptance (all green): a serialized blueprint runs bit-identical (C1) to its
Rust-built twin; serializer/loader are inverse (canonical idempotence, incl. a
recursive nested composite); unknown-type and unsupported-version fail named,
never panicking. cargo test --workspace 748 passed; clippy --all-targets
-D warnings clean.

closes #155
This commit is contained in:
2026-06-29 14:54:45 +02:00
parent b2278538ab
commit d5602ec5ad
11 changed files with 609 additions and 7 deletions
+2
View File
@@ -46,6 +46,7 @@ mod sma;
mod sqrt;
mod stop_rule;
mod sub;
mod vocabulary;
mod vol_slippage_cost;
pub use add::Add;
pub use and::And;
@@ -82,4 +83,5 @@ pub use sma::Sma;
pub use sqrt::Sqrt;
pub use stop_rule::FixedStop;
pub use sub::Sub;
pub use vocabulary::std_vocabulary;
pub use vol_slippage_cost::VolSlippageCost;
+110
View File
@@ -0,0 +1,110 @@
//! The closed, compiled-in dispatch from a serialized node **type identity** to
//! its `aura-std` builder factory — the load-side counterpart to the type label
//! a blueprint serializes (`PrimitiveBuilder::label`). This is **not** a dynamic
//! registry (domain invariant 9): it is a `match` the crate that *owns* the
//! nodes writes, over its own closed, compiled-in vocabulary (C24 — nodes are
//! referenced "by compiled-in type identity", never a by-name marketplace). The
//! engine cannot hold this table (it does not depend on `aura-std`); a loader
//! takes it as an injected resolver, and a project `cdylib` later supplies its
//! own (C16) through the same seam.
//!
//! Scope (#155): only the **zero-argument** `Type::builder()` factories are
//! resolvable. Builders that take structural construction arguments
//! (`LinComb(arity)`, `CostSum(n_costs)`, `SimBroker(pip_size)`, `Session(..)`)
//! and the recording sinks (`Recorder`/`GatedRecorder`/`SeriesReducer`, which
//! capture an `mpsc::Sender`) are deliberately absent — an absent `type_id`
//! yields `None`, so the loader fails cleanly (`LoadError::UnknownNodeType`)
//! rather than guessing. Serialising structural-axis construction args is a
//! later, additive extension (#156/C20).
use crate::{
Add, And, Bias, CarryCost, ConstantCost, Delay, Ema, EqConst, FixedStop, Gt, Latch, LongOnly,
Mul, PositionManagement, Resample, RollingMax, RollingMin, Sizer, Sma, Sqrt, Sub,
VolSlippageCost,
};
use aura_core::PrimitiveBuilder;
/// Resolve a serialized node type identity (the `PrimitiveBuilder::label`, e.g.
/// `"SMA"`) to a fresh, unbound builder from the node's own factory. Returns
/// `None` for any identity outside the zero-arg `aura-std` vocabulary.
pub fn std_vocabulary(type_id: &str) -> Option<PrimitiveBuilder> {
Some(match type_id {
"Add" => Add::builder(),
"And" => And::builder(),
"Bias" => Bias::builder(),
"CarryCost" => CarryCost::builder(),
"ConstantCost" => ConstantCost::builder(),
"Delay" => Delay::builder(),
"EMA" => Ema::builder(),
"EqConst" => EqConst::builder(),
"FixedStop" => FixedStop::builder(),
"Gt" => Gt::builder(),
"Latch" => Latch::builder(),
"LongOnly" => LongOnly::builder(),
"Mul" => Mul::builder(),
"PositionManagement" => PositionManagement::builder(),
"Resample" => Resample::builder(),
"RollingMax" => RollingMax::builder(),
"RollingMin" => RollingMin::builder(),
"Sizer" => Sizer::builder(),
"SMA" => Sma::builder(),
"Sqrt" => Sqrt::builder(),
"Sub" => Sub::builder(),
"VolSlippageCost" => VolSlippageCost::builder(),
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::std_vocabulary;
/// Every key in the closed vocabulary resolves to a builder that carries
/// that exact type label back, and only the zero-arg keys do. The label
/// round-trip is the property: a typo in any match key (the silent-
/// unresolvable failure this resolver guards against) breaks it, as does a
/// key mapped to the wrong builder; construction-arg nodes and sinks stay
/// absent so the loader fails cleanly rather than guessing.
#[test]
fn std_vocabulary_resolves_known_and_rejects_unknown() {
// every zero-arg key round-trips: lookup -> builder -> same type label.
// (this list is the resolver's whole vocabulary; a missing/typo'd arm
// fails the lookup, a mis-mapped arm fails the label assertion.)
for type_id in [
"Add",
"And",
"Bias",
"CarryCost",
"ConstantCost",
"Delay",
"EMA",
"EqConst",
"FixedStop",
"Gt",
"Latch",
"LongOnly",
"Mul",
"PositionManagement",
"Resample",
"RollingMax",
"RollingMin",
"Sizer",
"SMA",
"Sqrt",
"Sub",
"VolSlippageCost",
] {
let builder =
std_vocabulary(type_id).unwrap_or_else(|| panic!("{type_id} is in the vocabulary"));
assert_eq!(
builder.label(),
type_id,
"resolver key must round-trip to the same type label"
);
}
// an unknown id, a construction-arg node, and a sink are all absent
assert!(std_vocabulary("nope").is_none());
assert!(std_vocabulary("LinComb").is_none());
assert!(std_vocabulary("Recorder").is_none());
}
}