d5602ec5ad
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
290 lines
11 KiB
Rust
290 lines
11 KiB
Rust
//! The closed four-type scalar set streamed on aura's hot path (C7): `i64`,
|
|
//! `f64`, `bool`, and `Timestamp` (epoch-ns UTC). The raw value is a type-erased
|
|
//! [`Cell`] — one 64-bit word, no tag; a [`Scalar`] pairs a `Cell` with its
|
|
//! [`ScalarKind`] for the dynamic boundaries. Both are `Copy` PODs — no heap, no
|
|
//! reference counting.
|
|
|
|
use crate::cell::Cell;
|
|
|
|
/// Canonical engine time: epoch-nanoseconds UTC. Newtype over `i64` (C7).
|
|
/// `Default` (epoch 0) lets `Column<Timestamp>` pre-size its ring.
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize)]
|
|
pub struct Timestamp(pub i64);
|
|
|
|
/// The kind tag of a scalar / column, used for the edge-time type check (C7).
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
|
pub enum ScalarKind {
|
|
I64,
|
|
F64,
|
|
Bool,
|
|
Timestamp,
|
|
}
|
|
|
|
/// One scalar value: a self-describing tagged union of the four base types — the
|
|
/// carrier for the dynamic boundaries (builder binding, serialization,
|
|
/// rendering), where a value travels detached from any co-present schema and so
|
|
/// must remember its own type. Its type-erased projection is [`Cell`], the
|
|
/// tag-free word the SoA hot path reads (the kind living at the column / edge,
|
|
/// C7). `Scalar` and `Cell` are **disjoint**, bridged by
|
|
/// [`from_cell`](Self::from_cell) (decode) and [`cell`](Self::cell) (encode).
|
|
///
|
|
/// Reading is the caller's responsibility: each `as_*` accessor matches the
|
|
/// expected variant and **panics** on a mismatch (a caller bug, not a
|
|
/// recoverable error), handing back the **native** value.
|
|
///
|
|
/// `PartialEq` is the derived value equality: same variant, then native payload
|
|
/// compare — so `f64` keeps IEEE-754 semantics (`NaN != NaN`, `+0.0 == -0.0`),
|
|
/// and a kind mismatch is never equal even when the words would coincide
|
|
/// (`i64(0) != f64(0.0)`, distinct variants). serde's externally-tagged enum
|
|
/// default is the self-describing wire form (`{"I64": 10}`, `{"F64": 2.5}`).
|
|
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
|
pub enum Scalar {
|
|
I64(i64),
|
|
F64(f64),
|
|
Bool(bool),
|
|
Timestamp(Timestamp),
|
|
}
|
|
|
|
impl Scalar {
|
|
/// Construct an `i64` scalar.
|
|
pub fn i64(v: i64) -> Self {
|
|
Scalar::I64(v)
|
|
}
|
|
/// Construct an `f64` scalar.
|
|
pub fn f64(v: f64) -> Self {
|
|
Scalar::F64(v)
|
|
}
|
|
/// Construct a `bool` scalar.
|
|
pub fn bool(v: bool) -> Self {
|
|
Scalar::Bool(v)
|
|
}
|
|
/// Construct a [`Timestamp`] scalar.
|
|
pub fn ts(v: Timestamp) -> Self {
|
|
Scalar::Timestamp(v)
|
|
}
|
|
|
|
/// Decode a tag-free [`Cell`] into a `Scalar` by naming its `kind` — the
|
|
/// inverse of [`cell`](Self::cell). Used where the kind comes from a
|
|
/// co-present schema rather than from the value itself: a validated param
|
|
/// point carries its values as bare cells (C7 — the kind lives once, in the
|
|
/// co-indexed `ParamSpec`), and `zip_params` recombines the two into the
|
|
/// self-describing `Scalar` at the render boundary. The cell's bits are read
|
|
/// per `kind`; correctness rests on `kind` actually describing them (the
|
|
/// bootstrap's edge-time check, not a per-value tag) — but the result is an
|
|
/// honestly-typed value, so no skewed kind/bits pairing survives downstream.
|
|
pub fn from_cell(kind: ScalarKind, cell: Cell) -> Self {
|
|
match kind {
|
|
ScalarKind::I64 => Scalar::I64(cell.i64()),
|
|
ScalarKind::F64 => Scalar::F64(cell.f64()),
|
|
ScalarKind::Bool => Scalar::Bool(cell.bool()),
|
|
ScalarKind::Timestamp => Scalar::Timestamp(cell.ts()),
|
|
}
|
|
}
|
|
|
|
/// Encode to the tag-free [`Cell`], kind dropped — the strip side of the
|
|
/// dynamic boundary (the inverse of [`from_cell`](Self::from_cell)). Used by
|
|
/// the param-plane frontend to hand a validated value to the cell-carrying
|
|
/// construction base, once the kind has been checked against the slot it no
|
|
/// longer needs to ride along.
|
|
pub fn cell(self) -> Cell {
|
|
match self {
|
|
Scalar::I64(v) => Cell::from_i64(v),
|
|
Scalar::F64(v) => Cell::from_f64(v),
|
|
Scalar::Bool(v) => Cell::from_bool(v),
|
|
Scalar::Timestamp(v) => Cell::from_ts(v),
|
|
}
|
|
}
|
|
|
|
/// The kind tag of this scalar (the variant discriminant).
|
|
pub fn kind(self) -> ScalarKind {
|
|
match self {
|
|
Scalar::I64(_) => ScalarKind::I64,
|
|
Scalar::F64(_) => ScalarKind::F64,
|
|
Scalar::Bool(_) => ScalarKind::Bool,
|
|
Scalar::Timestamp(_) => ScalarKind::Timestamp,
|
|
}
|
|
}
|
|
|
|
/// Read as `i64`. The caller asserts the kind by choosing this accessor; a
|
|
/// mismatch is a caller bug (panics).
|
|
pub fn as_i64(self) -> i64 {
|
|
match self {
|
|
Scalar::I64(v) => v,
|
|
other => panic!("as_i64 on {:?}", other.kind()),
|
|
}
|
|
}
|
|
/// Read as `f64`. Caller asserts the kind (panics on mismatch).
|
|
pub fn as_f64(self) -> f64 {
|
|
match self {
|
|
Scalar::F64(v) => v,
|
|
other => panic!("as_f64 on {:?}", other.kind()),
|
|
}
|
|
}
|
|
/// Read as `bool`. Caller asserts the kind (panics on mismatch).
|
|
pub fn as_bool(self) -> bool {
|
|
match self {
|
|
Scalar::Bool(v) => v,
|
|
other => panic!("as_bool on {:?}", other.kind()),
|
|
}
|
|
}
|
|
/// Read as [`Timestamp`]. Caller asserts the kind (panics on mismatch).
|
|
pub fn as_ts(self) -> Timestamp {
|
|
match self {
|
|
Scalar::Timestamp(v) => v,
|
|
other => panic!("as_ts on {:?}", other.kind()),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<i64> for Scalar {
|
|
fn from(v: i64) -> Self {
|
|
Scalar::i64(v)
|
|
}
|
|
}
|
|
impl From<f64> for Scalar {
|
|
fn from(v: f64) -> Self {
|
|
Scalar::f64(v)
|
|
}
|
|
}
|
|
impl From<bool> for Scalar {
|
|
fn from(v: bool) -> Self {
|
|
Scalar::bool(v)
|
|
}
|
|
}
|
|
impl From<Timestamp> for Scalar {
|
|
fn from(v: Timestamp) -> Self {
|
|
Scalar::ts(v)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn kind_matches_variant() {
|
|
assert_eq!(Scalar::i64(1).kind(), ScalarKind::I64);
|
|
assert_eq!(Scalar::f64(1.0).kind(), ScalarKind::F64);
|
|
assert_eq!(Scalar::bool(true).kind(), ScalarKind::Bool);
|
|
assert_eq!(Scalar::ts(Timestamp(1)).kind(), ScalarKind::Timestamp);
|
|
}
|
|
|
|
#[test]
|
|
fn from_widenings() {
|
|
assert_eq!(Scalar::from(7i64), Scalar::i64(7));
|
|
assert_eq!(Scalar::from(2.5f64), Scalar::f64(2.5));
|
|
assert_eq!(Scalar::from(true), Scalar::bool(true));
|
|
assert_eq!(Scalar::from(Timestamp(9)), Scalar::ts(Timestamp(9)));
|
|
}
|
|
|
|
#[test]
|
|
fn bare_literal_infers_kind_through_into_scalar() {
|
|
// Authoring layers that lower raw literals via `impl Into<Scalar>` rely on
|
|
// a bare (unsuffixed) integer literal inferring as `i64` and a bare float
|
|
// literal as `f64` — because `i64`/`f64`/`bool` are the only `From` impls,
|
|
// each literal class resolves to exactly one of them. Adding a second
|
|
// integer `From` impl (e.g. `From<i32>`) would make `lower(2)` ambiguous
|
|
// and break this, loudly, here rather than silently at the call site.
|
|
fn lower(v: impl Into<Scalar>) -> Scalar {
|
|
v.into()
|
|
}
|
|
assert_eq!(lower(2), Scalar::i64(2));
|
|
assert_eq!(lower(0.5), Scalar::f64(0.5));
|
|
}
|
|
|
|
#[test]
|
|
fn timestamp_orders_and_defaults() {
|
|
assert!(Timestamp(1) < Timestamp(2));
|
|
assert_eq!(Timestamp::default(), Timestamp(0));
|
|
}
|
|
|
|
#[test]
|
|
fn scalar_value_accessors_return_native_payload() {
|
|
assert_eq!(Scalar::i64(3).as_i64(), 3);
|
|
assert_eq!(Scalar::f64(0.5).as_f64(), 0.5);
|
|
assert!(Scalar::bool(true).as_bool());
|
|
assert_eq!(Scalar::ts(Timestamp(7)).as_ts(), Timestamp(7));
|
|
}
|
|
|
|
#[test]
|
|
fn from_cell_and_cell_are_inverse() {
|
|
// cell() strips the kind; from_cell re-attaches it — the dynamic-boundary
|
|
// round trip the param-plane frontend/base split relies on (strip to hand a
|
|
// validated value to the cell base, re-attach to render it).
|
|
for s in [Scalar::i64(7), Scalar::f64(2.5), Scalar::bool(true), Scalar::ts(Timestamp(9))] {
|
|
assert_eq!(Scalar::from_cell(s.kind(), s.cell()), s);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn scalar_eq_is_value_not_bitwise() {
|
|
// f64 follows IEEE-754 exactly as the former enum did: a NaN is never
|
|
// equal to itself, and +0.0 equals -0.0 — value equality, NOT the
|
|
// bitwise equality the underlying `Cell` carries (those two cases are
|
|
// precisely where bit- and value-equality diverge).
|
|
assert_ne!(Scalar::f64(f64::NAN), Scalar::f64(f64::NAN));
|
|
assert_eq!(Scalar::f64(0.0), Scalar::f64(-0.0));
|
|
assert_eq!(Scalar::f64(2.5), Scalar::f64(2.5));
|
|
// i64 / bool / timestamp compare by native value.
|
|
assert_eq!(Scalar::i64(7), Scalar::i64(7));
|
|
assert_ne!(Scalar::i64(7), Scalar::i64(8));
|
|
assert_eq!(Scalar::bool(true), Scalar::bool(true));
|
|
assert_ne!(Scalar::bool(true), Scalar::bool(false));
|
|
assert_eq!(Scalar::ts(Timestamp(9)), Scalar::ts(Timestamp(9)));
|
|
// A kind mismatch is never equal — even when the words coincide:
|
|
// i64(0) and f64(0.0) share the bit pattern 0; bool(true) and i64(1)
|
|
// share 1. Value equality still separates them by kind.
|
|
assert_ne!(Scalar::i64(0), Scalar::f64(0.0));
|
|
assert_ne!(Scalar::i64(1), Scalar::bool(true));
|
|
}
|
|
|
|
#[test]
|
|
fn scalar_is_copy() {
|
|
let s = Scalar::f64(1.0);
|
|
let a = s;
|
|
let b = s; // Copy: `s` still usable after move-by-value
|
|
assert_eq!(a, b);
|
|
assert_eq!(s, a);
|
|
}
|
|
|
|
#[test]
|
|
fn timestamp_serde_round_trips() {
|
|
let ts = Timestamp(1_700_000_000_000_000_000);
|
|
let json = serde_json::to_string(&ts).expect("serialize Timestamp");
|
|
// a newtype struct serializes transparently as its inner i64 — this is
|
|
// what lets RunManifest's `window` render as a [from, to] integer array
|
|
assert_eq!(json, "1700000000000000000");
|
|
let back: Timestamp = serde_json::from_str(&json).expect("deserialize Timestamp");
|
|
assert_eq!(back, ts);
|
|
}
|
|
|
|
#[test]
|
|
fn scalar_serde_round_trips() {
|
|
// serde's externally-tagged enum default: the kind is the JSON key, the
|
|
// native value the body — the self-describing wire form the run record
|
|
// (RunManifest.params) relies on. i64 and f64 keep their distinct shapes,
|
|
// so a length and a scale never collapse to the same token.
|
|
assert_eq!(serde_json::to_string(&Scalar::i64(42)).unwrap(), r#"{"I64":42}"#);
|
|
assert_eq!(serde_json::to_string(&Scalar::f64(2.5)).unwrap(), r#"{"F64":2.5}"#);
|
|
for s in [
|
|
Scalar::i64(42),
|
|
Scalar::f64(2.5),
|
|
Scalar::bool(true),
|
|
Scalar::ts(Timestamp(1_700_000_000_000_000_000)),
|
|
] {
|
|
let json = serde_json::to_string(&s).expect("serialize Scalar");
|
|
let back: Scalar = serde_json::from_str(&json).expect("deserialize Scalar");
|
|
assert_eq!(back, s, "round-trip failed: {json}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn scalar_kind_serde_is_variant_name() {
|
|
// canonical kind encoding: a bare variant-name string, both directions.
|
|
assert_eq!(serde_json::to_string(&ScalarKind::I64).unwrap(), "\"I64\"");
|
|
assert_eq!(serde_json::to_string(&ScalarKind::F64).unwrap(), "\"F64\"");
|
|
let back: ScalarKind = serde_json::from_str("\"I64\"").unwrap();
|
|
assert_eq!(back, ScalarKind::I64);
|
|
}
|
|
}
|