feat(aura-core,aura-std,aura-engine,aura-cli): inject a param-set vector at bootstrap (#31)
Cycle B of milestone "The World — parameter-space & sweep". A blueprint is now value-empty: a leaf holds a param-generic recipe, not a built node, and a positional Scalar vector is bound slot-by-slot at bootstrap — so one blueprint bootstraps into many distinct instances under different vectors, with no cdylib rebuild (C12/C19). This is the binding primitive a sweep (#32) drives. Ratified design (brainstorm): value-empty reconstruct-through-new() over mutate-in-place and over a default-bearing variant. The value lives only in the injected vector (no baked default), keeping the blueprint a pure param-generic recipe (C19); every injected value flows through the node's own constructor (the single sizing/validation gate). - aura-core: LeafFactory { name, params, build } — the recipe (params -> sized node through `new`); Scalar::as_i64/as_f64 value accessors. Node trait unchanged. - aura-std: each of the 7 nodes exposes factory() (SMA length:I64, Exposure scale:F64, LinComb arity x weights[i]:F64; Sub/Add/SimBroker/Recorder paramless, capturing their non-param construction args — pip_size, the Recorder channel). - aura-engine: BlueprintNode::Leaf(LeafFactory), From<LeafFactory>; param_space() reads factory.params() pre-build; compile_with_params/bootstrap_with_params build each leaf from its kind-checked slice while lowering (build-then-wire), arity checked up front via param_space().len(); CompileError::{ParamKindMismatch, ParamArity}. compile/inline/edge-rewrite are structurally unchanged, so the compilat stays bit-identical for a given point (the bit-identity and both param_space mirror tests stay green). The vestigial pre-build Composite::schema / BlueprintNode::schema (no live caller — interface resolution is structural on the built flat nodes) are removed. - aura-cli: the blueprint view labels leaves by bare type via LeafFactory::label() (`[SMA]`) — the ascii-dag renderer cannot render wide cluster-sibling labels (see spec); the compiled view still labels valued (`SMA(2)`). The sample supplies its point as a vector; the mis-wiring swap moved to the compiled view. The #34 dual-traversal drift hazard is subsumed: compile_with_params consumes the vector in the same recipe walk param_space() reports, so the two share one traversal. Verified: cargo build/test --workspace green (127 tests, incl. bit-identity, both mirror tests, and 4 new injection tests — different-vector-different-run, kind mismatch, arity, determinism); clippy --workspace --all-targets -D warnings clean; `aura graph` blueprint view renders cleanly. Scope: one vector -> one instance. Deferred: sweep enumeration (#32), domain validation (#32/C20), single-run authoring convenience (#35). closes #31
This commit is contained in:
@@ -39,5 +39,5 @@ pub use any::AnyColumn;
|
||||
pub use column::{Column, Window};
|
||||
pub use ctx::Ctx;
|
||||
pub use error::KindMismatch;
|
||||
pub use node::{FieldSpec, Firing, InputSpec, Node, NodeSchema, ParamSpec};
|
||||
pub use node::{FieldSpec, Firing, InputSpec, LeafFactory, Node, NodeSchema, ParamSpec};
|
||||
pub use scalar::{Scalar, ScalarKind, Timestamp};
|
||||
|
||||
@@ -57,6 +57,53 @@ pub struct ParamSpec {
|
||||
pub kind: ScalarKind,
|
||||
}
|
||||
|
||||
/// A param-generic blueprint leaf (C19): a node's declared tunable params plus a
|
||||
/// closure that builds a sized instance through the node's own constructor (the
|
||||
/// single sizing/validation gate). A blueprint holds these recipes, never built
|
||||
/// instances, so it stays value-empty until a param-set is injected (C19/C23).
|
||||
pub struct LeafFactory {
|
||||
name: &'static str,
|
||||
params: Vec<ParamSpec>,
|
||||
// The build closure's type is exactly the recipe contract (a param slice in, a
|
||||
// boxed node out); a type alias would not clarify it.
|
||||
#[allow(clippy::type_complexity)]
|
||||
build: Box<dyn Fn(&[Scalar]) -> Box<dyn Node>>,
|
||||
}
|
||||
|
||||
impl LeafFactory {
|
||||
/// `name` is the param-generic render label (the node type, e.g. `"SMA"`);
|
||||
/// `params` the declared knobs; `build` constructs a sized node from a
|
||||
/// kind-checked param slice.
|
||||
pub fn new(
|
||||
name: &'static str,
|
||||
params: Vec<ParamSpec>,
|
||||
build: impl Fn(&[Scalar]) -> Box<dyn Node> + 'static,
|
||||
) -> Self {
|
||||
Self { name, params, build: Box::new(build) }
|
||||
}
|
||||
/// The declared tunable params (read by `Blueprint::param_space`, pre-build).
|
||||
pub fn params(&self) -> &[ParamSpec] {
|
||||
&self.params
|
||||
}
|
||||
/// Build a sized node from its param slice (the slice is kind-checked by the
|
||||
/// caller before this runs).
|
||||
pub fn build(&self, params: &[Scalar]) -> Box<dyn Node> {
|
||||
(self.build)(params)
|
||||
}
|
||||
/// The param-generic render label for the blueprint view (C22 "structure
|
||||
/// before"): just the node type, e.g. `SMA`. A value-empty recipe has no
|
||||
/// values to show; the tunable knobs are surfaced by `Blueprint::param_space`,
|
||||
/// not in the graph. The label stays the bare type because the `ascii-dag`
|
||||
/// renderer writes a label verbatim on one line (no wrapping) and overlaps two
|
||||
/// wide sibling boxes inside a cluster subgraph — appending the knob names
|
||||
/// (`SMA(length)`) would garble the blueprint view, and domain labels grow
|
||||
/// unboundedly wide. The compiled view labels the built node valued (`SMA(2)`)
|
||||
/// via `Node::label`, unaffected.
|
||||
pub fn label(&self) -> String {
|
||||
self.name.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// A node's declared interface: its inputs (in order) and its output record — an
|
||||
/// ordered list of named base columns; length 1 is a scalar (the degenerate
|
||||
/// case), and an **empty** `output` (`vec![]`) declares a **pure consumer**
|
||||
@@ -99,6 +146,17 @@ pub trait Node {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A minimal node with an empty schema, reused across the label / factory tests.
|
||||
struct Bare;
|
||||
impl Node for Bare {
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema { inputs: vec![], output: vec![], params: vec![] }
|
||||
}
|
||||
fn eval(&mut self, _ctx: Ctx<'_>) -> Option<&[Scalar]> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_spec_carries_firing() {
|
||||
let a = InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any };
|
||||
@@ -111,18 +169,29 @@ mod tests {
|
||||
#[test]
|
||||
fn default_label_is_placeholder() {
|
||||
// A node that does not override label() falls back to the placeholder.
|
||||
struct Bare;
|
||||
impl Node for Bare {
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema { inputs: vec![], output: vec![], params: vec![] }
|
||||
}
|
||||
fn eval(&mut self, _ctx: Ctx<'_>) -> Option<&[Scalar]> {
|
||||
None
|
||||
}
|
||||
}
|
||||
assert_eq!(Bare.label(), "node");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaf_factory_label_is_the_bare_type() {
|
||||
// param-generic: the label is just the node type, no knob suffix (the
|
||||
// ascii-dag blueprint view cannot render wide cluster-sibling labels).
|
||||
let with = LeafFactory::new(
|
||||
"SMA",
|
||||
vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
|
||||
|_| Box::new(Bare),
|
||||
);
|
||||
assert_eq!(with.label(), "SMA");
|
||||
let none = LeafFactory::new("Sub", vec![], |_| Box::new(Bare));
|
||||
assert_eq!(none.label(), "Sub");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaf_factory_build_runs_the_closure() {
|
||||
let f = LeafFactory::new("Bare", vec![], |_| Box::new(Bare));
|
||||
assert_eq!(f.build(&[]).schema().params, Vec::<ParamSpec>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_carries_declared_params() {
|
||||
let s = NodeSchema {
|
||||
|
||||
@@ -35,6 +35,14 @@ impl Scalar {
|
||||
Scalar::Ts(_) => ScalarKind::Timestamp,
|
||||
}
|
||||
}
|
||||
/// The `i64` payload, or `None` if this scalar is not an `I64`.
|
||||
pub fn as_i64(self) -> Option<i64> {
|
||||
if let Scalar::I64(v) = self { Some(v) } else { None }
|
||||
}
|
||||
/// The `f64` payload, or `None` if this scalar is not an `F64`.
|
||||
pub fn as_f64(self) -> Option<f64> {
|
||||
if let Scalar::F64(v) = self { Some(v) } else { None }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i64> for Scalar {
|
||||
@@ -84,6 +92,14 @@ mod tests {
|
||||
assert_eq!(Timestamp::default(), Timestamp(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalar_value_accessors_are_kind_exact() {
|
||||
assert_eq!(Scalar::I64(3).as_i64(), Some(3));
|
||||
assert_eq!(Scalar::I64(3).as_f64(), None);
|
||||
assert_eq!(Scalar::F64(0.5).as_f64(), Some(0.5));
|
||||
assert_eq!(Scalar::F64(0.5).as_i64(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalar_is_copy() {
|
||||
let s = Scalar::F64(1.0);
|
||||
|
||||
Reference in New Issue
Block a user