953d04a774
Composite gains doc: Option<String> (the prose twin of name, a C23 debug symbol): with_doc/doc() on Composite, a doc knob on GraphBuilder, a Tier-1 additive-optional CompositeData field (no format-version bump; absent-field documents keep their exact bytes), identity-stripped like the name. The graph model emits an optional trailing doc fragment in both scopes; json_str is hardened for free text (\n/\t/\r named, control chars as \u00XX — the doc is the first multi-line value through it). e2e: a hand-authored doc'd blueprint renders with the doc embedded; the doc moves the content id but never the identity id. refs #125
3346 lines
142 KiB
Rust
3346 lines
142 KiB
Rust
//! The construction layer (C9/C19/C23): a named, param-generic graph-as-data
|
|
//! ([`Composite`]) that **compiles** to the flat, type-erased instance the run loop
|
|
//! already runs (a [`crate::FlatGraph`]). The unit of reuse is the
|
|
//! [`Composite`]: a nestable sub-graph fragment exposing an output record (one port,
|
|
//! K re-exported fields; C8) and input roles (each open, or — at the root —
|
|
//! source-bound). `compile` **inlines** the nesting into the flat `FlatGraph` the
|
|
//! unchanged [`crate::Harness::bootstrap`] consumes; the root composite IS the
|
|
//! blueprint (there is no separate `Blueprint` type — a fully source-bound composite
|
|
//! is the runnable root).
|
|
//!
|
|
//! The flat graph is wired by raw index, **not by name** (C23): a composite's
|
|
//! boundary dissolves at compile time; field/role names, where kept, are
|
|
//! non-load-bearing debug symbols (as `FieldSpec.name` already is). This module
|
|
//! adds no optimisation pass (CSE/DCE, sweep-invariant hoisting are deferred,
|
|
//! C23) and no external dependency (C16).
|
|
|
|
use aura_core::{
|
|
Cell, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, Scalar, ScalarKind,
|
|
};
|
|
|
|
use crate::harness::{BootstrapError, Edge, FlatGraph, Harness, SourceSpec, Target};
|
|
use crate::sweep::sweep;
|
|
use crate::{GridSpace, ParamRange, RandomSpace, RunReport, SweepFamily};
|
|
|
|
/// One re-exported field of a composite's output record: an interior
|
|
/// `(node, output-field)` surfaced at the boundary under `name`. `name` is a
|
|
/// non-load-bearing render/debug symbol (C23) — like `FieldSpec.name` and
|
|
/// `Composite.name`, it does not reach the flat graph.
|
|
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
|
pub struct OutField {
|
|
pub node: usize,
|
|
pub field: usize,
|
|
pub name: String,
|
|
}
|
|
|
|
/// A blueprint item: a primitive node or a nested composite. Both present a declared
|
|
/// interface (typed inputs + one output) to the enclosing graph.
|
|
pub enum BlueprintNode {
|
|
Primitive(PrimitiveBuilder),
|
|
Composite(Composite),
|
|
}
|
|
|
|
/// Ergonomic lift: a param-generic primitive recipe becomes a `Primitive` blueprint item.
|
|
impl From<PrimitiveBuilder> for BlueprintNode {
|
|
fn from(builder: PrimitiveBuilder) -> Self {
|
|
BlueprintNode::Primitive(builder)
|
|
}
|
|
}
|
|
|
|
/// Ergonomic lift: a nested composite becomes a `Composite` blueprint item, so a
|
|
/// builder's `add` can accept a sub-graph the same way it accepts a primitive.
|
|
impl From<Composite> for BlueprintNode {
|
|
fn from(c: Composite) -> Self {
|
|
BlueprintNode::Composite(c)
|
|
}
|
|
}
|
|
|
|
impl BlueprintNode {
|
|
/// The node's declared signature, pre-build, uniform across both arms — a
|
|
/// primitive returns its builder's declared schema; a composite derives it from
|
|
/// its interior. This is "every node has a signature in the blueprint".
|
|
pub fn signature(&self) -> NodeSchema {
|
|
match self {
|
|
BlueprintNode::Primitive(b) => b.schema().clone(),
|
|
BlueprintNode::Composite(c) => derive_signature(c),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Derive a composite's signature from its interior (no build): one input port per
|
|
/// input role (kind = the role's interior target slot kind; firing is a non-load-
|
|
/// bearing `Any` placeholder — a composite's ports dissolve at inline, only the kind
|
|
/// is consulted by an enclosing graph's wiring check), one output field per
|
|
/// re-exported `OutField` (kind = the interior producer's field kind), and the
|
|
/// aggregated param-space.
|
|
fn derive_signature(c: &Composite) -> NodeSchema {
|
|
let inputs = c
|
|
.input_roles()
|
|
.iter()
|
|
.map(|role| {
|
|
let kind = role
|
|
.targets
|
|
.first()
|
|
.map(|t| interior_slot_kind(c.nodes(), c.edges(), t))
|
|
.unwrap_or(ScalarKind::F64);
|
|
PortSpec { kind, firing: Firing::Any, name: role.name.clone() }
|
|
})
|
|
.collect();
|
|
let output = c
|
|
.output()
|
|
.iter()
|
|
.map(|of| {
|
|
// bounds-total: a structurally-invalid OutField (out-of-range node/field)
|
|
// yields a placeholder kind, never a panic — the real fault is reported by
|
|
// validate_wiring's guarded output check (OutputPortOutOfRange). signature()
|
|
// is computed speculatively by the parent's edge/role/connectivity checks
|
|
// before the recursion validates this composite's interior (cycle 0040).
|
|
let kind = c
|
|
.nodes()
|
|
.get(of.node)
|
|
.and_then(|n| n.signature().output.get(of.field).map(|f| f.kind))
|
|
.unwrap_or(ScalarKind::F64);
|
|
FieldSpec { name: of.name.clone(), kind }
|
|
})
|
|
.collect();
|
|
let mut params = Vec::new();
|
|
collect_params(c.nodes(), c.gangs(), "", &mut params);
|
|
NodeSchema { inputs, output, params }
|
|
}
|
|
|
|
/// The scalar kind of the interior input slot a composite target addresses,
|
|
/// resolving one level (a target into a nested composite reads that composite's
|
|
/// derived input-port kind).
|
|
fn interior_slot_kind(nodes: &[BlueprintNode], _edges: &[Edge], t: &Target) -> ScalarKind {
|
|
// bounds-total: a target into a missing node/slot yields a placeholder kind, never a
|
|
// panic — the real fault is reported by validate_wiring's guarded index checks.
|
|
nodes
|
|
.get(t.node)
|
|
.and_then(|n| n.signature().inputs.get(t.slot).map(|p| p.kind))
|
|
.unwrap_or(ScalarKind::F64)
|
|
}
|
|
|
|
/// One named input role: role `r` (by position) fans the source value into
|
|
/// `targets`. The `name` is a non-load-bearing render symbol (C23); identity is
|
|
/// the role index, which survives lowering — the name does not.
|
|
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
|
pub struct Role {
|
|
pub name: String,
|
|
pub targets: Vec<Target>,
|
|
/// `None` = an open interior port (wired by the enclosing graph's edges);
|
|
/// `Some(kind)` = a bound ingestion feed of `kind` (only meaningful at the root,
|
|
/// where it lowers to a `FlatGraph` source). C3: sources bind at ingestion only.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub source: Option<ScalarKind>,
|
|
}
|
|
|
|
/// One public knob fanning into >=2 sibling params of one composite frame
|
|
/// (#61). Binding is structural, not advisory: the member addresses leave
|
|
/// `param_space()` and this gang's own address replaces them, so no consumer
|
|
/// can bind a member independently. Mirrors `Role`'s name/targets split (the
|
|
/// name is a C23 debug/axis symbol; the members are structure) and
|
|
/// `BoundParam`'s pos/name dual (pos identity-bearing, name the re-application
|
|
/// key).
|
|
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)]
|
|
pub struct Gang {
|
|
/// The public knob's name — a single path segment (no `.`).
|
|
pub name: String,
|
|
/// The shared scalar kind every member must declare.
|
|
pub kind: ScalarKind,
|
|
/// The fused params, canonical order ascending by `(node, pos)`.
|
|
pub members: Vec<GangMember>,
|
|
}
|
|
|
|
/// One ganged param slot: the declaring composite's node index plus the
|
|
/// slot's position in that node's ORIGINAL (pre-bind) param list, plus the
|
|
/// param's name (used to resolve against the shrunk open schema; blanked in
|
|
/// the identity projection like `BoundParam.name`).
|
|
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)]
|
|
pub struct GangMember {
|
|
pub node: usize,
|
|
pub pos: usize,
|
|
pub name: String,
|
|
}
|
|
|
|
/// A reusable sub-graph fragment compiled away by inlining (C9/C23). It is **not**
|
|
/// a [`Node`]: it is never `eval`'d. It holds interior items (local indices),
|
|
/// interior edges (local indices), input roles (role `r` fans into the interior
|
|
/// targets `input_roles[r]`), and the exposed output record (each entry
|
|
/// re-exports one interior `(node, field)` under a boundary name).
|
|
pub struct Composite {
|
|
name: String,
|
|
doc: Option<String>,
|
|
nodes: Vec<BlueprintNode>,
|
|
edges: Vec<Edge>,
|
|
input_roles: Vec<Role>,
|
|
output: Vec<OutField>,
|
|
gangs: Vec<Gang>,
|
|
}
|
|
|
|
impl Composite {
|
|
/// Build a composite from its authored name, interior items, interior edges
|
|
/// (local indices), input roles, and output record. The `name` is a
|
|
/// non-load-bearing render symbol (the cluster title for #13); it does not
|
|
/// reach the flat graph (the boundary dissolves at inline, C23).
|
|
pub fn new(
|
|
name: impl Into<String>,
|
|
nodes: Vec<BlueprintNode>,
|
|
edges: Vec<Edge>,
|
|
input_roles: Vec<Role>,
|
|
output: Vec<OutField>,
|
|
) -> Self {
|
|
Self { name: name.into(), doc: None, nodes, edges, input_roles, output, gangs: Vec::new() }
|
|
}
|
|
|
|
/// The authored render name (cluster title, #13). Non-load-bearing.
|
|
pub fn name(&self) -> &str {
|
|
&self.name
|
|
}
|
|
/// Attach the authored rationale — the prose twin of the `name` beside it
|
|
/// (a C23 debug symbol: render/tooltip surface only, stripped from the
|
|
/// identity projection, never reaching the flat graph). Fluent (#125).
|
|
pub fn with_doc(mut self, doc: impl Into<String>) -> Self {
|
|
self.doc = Some(doc.into());
|
|
self
|
|
}
|
|
/// The authored rationale, if any. Non-load-bearing (C23, #125).
|
|
pub fn doc(&self) -> Option<&str> {
|
|
self.doc.as_deref()
|
|
}
|
|
/// The interior blueprint items (read-only graph-as-data, C9).
|
|
pub fn nodes(&self) -> &[BlueprintNode] {
|
|
&self.nodes
|
|
}
|
|
/// The interior edges (local indices).
|
|
pub fn edges(&self) -> &[Edge] {
|
|
&self.edges
|
|
}
|
|
/// The input roles: role `r` fans into `input_roles()[r].targets` interior
|
|
/// targets, under the boundary name `input_roles()[r].name` (C23 — name is a
|
|
/// render symbol, identity is the role index).
|
|
pub fn input_roles(&self) -> &[Role] {
|
|
&self.input_roles
|
|
}
|
|
/// The exposed output record: each entry re-exports one interior
|
|
/// `(node, output-field)` under a boundary name (C8 — one port, K columns).
|
|
pub fn output(&self) -> &[OutField] {
|
|
&self.output
|
|
}
|
|
/// The gang table (empty for an un-ganged blueprint).
|
|
pub fn gangs(&self) -> &[Gang] {
|
|
&self.gangs
|
|
}
|
|
/// Install a gang table — the ONLY way a non-empty one enters a
|
|
/// `Composite`. Validates via `check_gangs`, so a constructed value's
|
|
/// gangs may be trusted downstream (the same authoring-edge trust
|
|
/// `lower_items` extends to param cells). All three minting boundaries
|
|
/// (GraphBuilder::build, GraphSession::finish, blueprint_from_json)
|
|
/// route through here — one predicate, three cadences (C24).
|
|
pub fn with_gangs(mut self, gangs: Vec<Gang>) -> Result<Composite, CompileError> {
|
|
check_gangs(&self.nodes, &gangs)?;
|
|
self.gangs = gangs;
|
|
Ok(self)
|
|
}
|
|
|
|
/// The aggregated, flat, path-qualified param-space (C12): every node's declared
|
|
/// params, concatenated in lowering order. Each param is `<node>.<param>` (the
|
|
/// node name = its instance name, default = lowercased type label), with the
|
|
/// composite path prefixed at every level including the root — so a top-level
|
|
/// leaf carries its own node segment (e.g. `sma.length`). Interior composite
|
|
/// names prefix via the recursion in `collect_params`.
|
|
pub fn param_space(&self) -> Vec<ParamSpec> {
|
|
let mut out = Vec::new();
|
|
collect_params(&self.nodes, &self.gangs, "", &mut out);
|
|
out
|
|
}
|
|
|
|
/// Compile this composite as the ROOT graph from a VALIDATED cell point — the
|
|
/// construction base (the cell side of the param-plane split). Structural
|
|
/// validation runs here (wiring, root roles, arity), but NOT the per-value kind
|
|
/// check: the cells are trusted to match the declared slot kinds, having been
|
|
/// checked at the authoring edge (`compile_with_params`, `GridSpace::new`, or
|
|
/// `bind`). Lowers by reading each cell as its declared kind.
|
|
pub fn compile_with_cells(self, point: &[Cell]) -> Result<FlatGraph, CompileError> {
|
|
// structural validation, all pre-build (no node constructed):
|
|
let space = self.param_space();
|
|
check_param_namespace_injective(&space)?;
|
|
validate_wiring(&self.nodes, &self.edges, &self.input_roles, &self.output)?;
|
|
check_root_roles_bound(&self.input_roles)?;
|
|
|
|
if point.len() != space.len() {
|
|
return Err(CompileError::ParamArity { expected: space.len(), got: point.len() });
|
|
}
|
|
|
|
// gang expansion (#61 task 2): project the public point back onto the raw,
|
|
// per-slot cursor order `lower_items` walks — a gang-free blueprint's map is
|
|
// the identity `[0, 1, 2, ...]`, so `raw == point` and behaviour is
|
|
// byte-identical to before gangs existed.
|
|
let mut map = Vec::new();
|
|
let mut next_public = 0usize;
|
|
expansion_map(&self.nodes, &self.gangs, &mut next_public, &mut map);
|
|
let raw: Vec<Cell> = map.iter().map(|&j| point[j]).collect();
|
|
|
|
let mut flat_nodes: Vec<Box<dyn Node>> = Vec::new();
|
|
let mut flat_signatures: Vec<NodeSchema> = Vec::new();
|
|
let mut flat_edges: Vec<Edge> = Vec::new();
|
|
let mut cursor = 0usize;
|
|
|
|
let lowerings = lower_items(
|
|
self.nodes,
|
|
&raw,
|
|
&mut cursor,
|
|
&mut flat_nodes,
|
|
&mut flat_signatures,
|
|
&mut flat_edges,
|
|
)?;
|
|
|
|
for e in &self.edges {
|
|
for fe in rewrite_edge(e, &lowerings, &flat_signatures)? {
|
|
flat_edges.push(fe);
|
|
}
|
|
}
|
|
|
|
// each bound root role lowers to a flat source, in role-declaration order
|
|
let mut flat_sources: Vec<SourceSpec> = Vec::with_capacity(self.input_roles.len());
|
|
for role in &self.input_roles {
|
|
let kind = role.source.expect("root role bound (checked above)");
|
|
let mut targets: Vec<Target> = Vec::new();
|
|
for t in &role.targets {
|
|
targets.extend(resolve_target(t, &lowerings)?);
|
|
}
|
|
flat_sources.push(SourceSpec { kind, targets });
|
|
}
|
|
|
|
Ok(FlatGraph { nodes: flat_nodes, signatures: flat_signatures, sources: flat_sources, edges: flat_edges })
|
|
}
|
|
|
|
/// Compile from a self-describing param vector — the authoring-edge frontend
|
|
/// over [`Self::compile_with_cells`]. Its sole added responsibility is the per-value
|
|
/// kind checksum (each value's `kind()` vs. the declared slot kind, C7's
|
|
/// authoring boundary); it then strips the validated values to tag-free cells
|
|
/// and delegates to the base. Arity is checked here too, so the checksum's zip
|
|
/// cannot silently skip a trailing slot.
|
|
pub fn compile_with_params(self, params: &[Scalar]) -> Result<FlatGraph, CompileError> {
|
|
let space = self.param_space();
|
|
// injective before arity, preserving the pre-split error order: a duplicate
|
|
// param path is reported regardless of how many values were supplied (e.g.
|
|
// `compile()` passes none). The base re-checks it for the direct cell path.
|
|
check_param_namespace_injective(&space)?;
|
|
if params.len() != space.len() {
|
|
return Err(CompileError::ParamArity { expected: space.len(), got: params.len() });
|
|
}
|
|
for (slot, (v, spec)) in params.iter().zip(&space).enumerate() {
|
|
if v.kind() != spec.kind {
|
|
return Err(CompileError::ParamKindMismatch { slot, expected: spec.kind, got: v.kind() });
|
|
}
|
|
}
|
|
let cells: Vec<Cell> = params.iter().map(|s| s.cell()).collect();
|
|
self.compile_with_cells(&cells)
|
|
}
|
|
|
|
/// No-param compile (errors `ParamArity` if any param is declared).
|
|
pub fn compile(self) -> Result<FlatGraph, CompileError> {
|
|
self.compile_with_params(&[])
|
|
}
|
|
|
|
/// Compile under an injected vector, then bootstrap the flat graph.
|
|
pub fn bootstrap_with_params(self, params: Vec<Scalar>) -> Result<Harness, CompileError> {
|
|
let flat = self.compile_with_params(¶ms)?;
|
|
Harness::bootstrap(flat).map_err(CompileError::Bootstrap)
|
|
}
|
|
|
|
/// Bootstrap from a VALIDATED cell point — the cell-side base of
|
|
/// [`Self::bootstrap_with_params`]. The sweep path uses this directly: its enumerated
|
|
/// point is already kind-checked by `GridSpace::new`, so it skips the frontend.
|
|
pub fn bootstrap_with_cells(self, point: &[Cell]) -> Result<Harness, CompileError> {
|
|
let flat = self.compile_with_cells(point)?;
|
|
Harness::bootstrap(flat).map_err(CompileError::Bootstrap)
|
|
}
|
|
|
|
/// No-param bootstrap.
|
|
pub fn bootstrap(self) -> Result<Harness, CompileError> {
|
|
self.bootstrap_with_params(vec![])
|
|
}
|
|
|
|
/// Begin binding this blueprint's knobs **by name** for a single run (the
|
|
/// fluent alternative to a positional `bootstrap_with_params` vector). The
|
|
/// bound name is the exact `param_space()` name — `<node>.<param>` at every
|
|
/// level, e.g. `sma_cross.fast.length` for a composite-interior knob and
|
|
/// `bias.scale` for a root-level knob.
|
|
pub fn with(self, name: &str, v: impl Into<Scalar>) -> Binder {
|
|
Binder { bp: self, bound: vec![(name.to_string(), v.into())] }
|
|
}
|
|
|
|
/// Begin binding this blueprint's knobs **by name** as sweep axes (the fluent
|
|
/// authoring alternative to a positional `GridSpace`). The bound name is the
|
|
/// exact `param_space()` name (path-qualified for a composite-interior knob,
|
|
/// bare for a root-level knob).
|
|
pub fn axis(self, name: &str, vals: impl IntoIterator<Item = impl Into<Scalar>>) -> SweepBinder {
|
|
let axis = vals.into_iter().map(Into::into).collect();
|
|
SweepBinder { bp: self, axes: vec![(name.to_string(), axis)] }
|
|
}
|
|
|
|
/// Begin binding this blueprint's knobs **by name** as random-sweep ranges (the
|
|
/// fluent authoring alternative to a positional `RandomSpace`). The bound name is
|
|
/// the exact `param_space()` name (path-qualified for a composite-interior knob,
|
|
/// bare for a root-level knob).
|
|
pub fn range(self, name: &str, range: ParamRange) -> RandomBinder {
|
|
RandomBinder { bp: self, ranges: vec![(name.to_string(), range)] }
|
|
}
|
|
}
|
|
|
|
/// A fault in resolving a named binding against `param_space()` — the authoring
|
|
/// layer over `bootstrap_with_params` / `sweep`. Name-qualified: each message
|
|
/// names the offending knob, not a slot index. The total error order is documented
|
|
/// on the resolution path; the first failing check wins.
|
|
#[derive(Debug, PartialEq)]
|
|
pub enum BindError {
|
|
/// A bound name matches no `param_space()` slot's exact name.
|
|
UnknownKnob(String),
|
|
/// A `param_space()` slot was left unbound.
|
|
MissingKnob(String),
|
|
/// A bound value's kind does not equal the slot's declared kind.
|
|
KindMismatch { knob: String, expected: ScalarKind, got: ScalarKind },
|
|
/// The same name was bound twice.
|
|
DuplicateBinding(String),
|
|
/// (sweep, iteration 2) An axis was given zero values.
|
|
EmptyAxis(String),
|
|
/// (random sweep) A named range was empty.
|
|
EmptyRange(String),
|
|
/// The resolved point passed name resolution but failed downstream bootstrap.
|
|
Compile(CompileError),
|
|
}
|
|
|
|
/// A fluent accumulator of named knob bindings for a single run, terminated by
|
|
/// [`Binder::bootstrap`]. Bindings are resolved against `param_space()` once, at
|
|
/// the terminal.
|
|
pub struct Binder {
|
|
bp: Composite,
|
|
bound: Vec<(String, Scalar)>,
|
|
}
|
|
|
|
impl Binder {
|
|
/// Bind one more knob by name; raw literals lower via `Into<Scalar>`.
|
|
pub fn with(mut self, name: &str, v: impl Into<Scalar>) -> Binder {
|
|
self.bound.push((name.to_string(), v.into()));
|
|
self
|
|
}
|
|
|
|
/// Resolve the accumulated bindings against `param_space()` and bootstrap.
|
|
pub fn bootstrap(self) -> Result<Harness, BindError> {
|
|
let space = self.bp.param_space();
|
|
check_param_namespace_injective(&space).map_err(BindError::Compile)?;
|
|
let point = resolve(&space, &self.bound)?;
|
|
self.bp.bootstrap_with_params(point).map_err(BindError::Compile)
|
|
}
|
|
}
|
|
|
|
/// A fluent accumulator of named sweep axes, terminated by [`SweepBinder::sweep`].
|
|
/// Axes are resolved against `param_space()` once, at the terminal; resolution is a
|
|
/// superset of `GridSpace::new`'s checks, so the grid it builds cannot fail.
|
|
pub struct SweepBinder {
|
|
bp: Composite,
|
|
axes: Vec<(String, Vec<Scalar>)>,
|
|
}
|
|
|
|
impl SweepBinder {
|
|
/// Bind one more sweep axis by name; raw literals lower via `Into<Scalar>`.
|
|
pub fn axis(mut self, name: &str, vals: impl IntoIterator<Item = impl Into<Scalar>>) -> SweepBinder {
|
|
self.axes.push((name.to_string(), vals.into_iter().map(Into::into).collect()));
|
|
self
|
|
}
|
|
|
|
/// The names of the axes that vary (more than one value) — the axes that
|
|
/// distinguish one grid point from another. Declaration order; callers derive
|
|
/// set membership (the key's token order comes from param-space slot order,
|
|
/// not from this list). A pure read accessor; it runs no sweep.
|
|
pub fn varying_axes(&self) -> Vec<String> {
|
|
self.axes.iter().filter(|(_, v)| v.len() > 1).map(|(n, _)| n.clone()).collect()
|
|
}
|
|
|
|
/// Resolve the named axes against `param_space()` into a positional grid and
|
|
/// run the disjoint sweep. `sweep` is [`SweepBinder::sweep_with_lattice`] with
|
|
/// the lattice dropped, so every existing caller is byte-unchanged.
|
|
pub fn sweep<F>(self, run_one: F) -> Result<SweepFamily, BindError>
|
|
where
|
|
F: Fn(&[Cell]) -> RunReport + Sync,
|
|
{
|
|
self.sweep_with_lattice(run_one).map(|(family, _lattice)| family)
|
|
}
|
|
|
|
/// As [`SweepBinder::sweep`], plus the grid's per-axis radixes (`axis_lens`,
|
|
/// in `param_space()` / odometer order). The lattice is what a plateau
|
|
/// neighbourhood walk needs (cycle 0077); only the engine's post-`resolve_axes`
|
|
/// grid holds it in the correct order.
|
|
pub fn sweep_with_lattice<F>(self, run_one: F) -> Result<(SweepFamily, Vec<usize>), BindError>
|
|
where
|
|
F: Fn(&[Cell]) -> RunReport + Sync,
|
|
{
|
|
let space = self.bp.param_space();
|
|
check_param_namespace_injective(&space).map_err(BindError::Compile)?;
|
|
let ordered = resolve_axes(&space, &self.axes)?;
|
|
let grid = GridSpace::new(&space, ordered)
|
|
.expect("named layer pre-validates arity/kind/non-empty");
|
|
let lattice = grid.axis_lens();
|
|
Ok((sweep(&grid, run_one), lattice))
|
|
}
|
|
}
|
|
|
|
/// A fluent accumulator of named random-sweep ranges, terminated by
|
|
/// [`RandomBinder::sweep`]. Ranges are resolved against `param_space()` once, at the
|
|
/// terminal; resolution is a superset of `RandomSpace::new`'s checks, so the space it
|
|
/// builds cannot fail.
|
|
pub struct RandomBinder {
|
|
bp: Composite,
|
|
ranges: Vec<(String, ParamRange)>,
|
|
}
|
|
|
|
impl RandomBinder {
|
|
/// Bind one more random-sweep range by name.
|
|
pub fn range(mut self, name: &str, range: ParamRange) -> RandomBinder {
|
|
self.ranges.push((name.to_string(), range));
|
|
self
|
|
}
|
|
|
|
/// Resolve the named ranges against `param_space()` into a positional
|
|
/// `RandomSpace` and run the disjoint sweep. `count`/`seed` are `RandomSpace`'s
|
|
/// extra inputs — the only signature difference from [`SweepBinder::sweep`].
|
|
pub fn sweep<F>(self, count: usize, seed: u64, run_one: F) -> Result<SweepFamily, BindError>
|
|
where
|
|
F: Fn(&[Cell]) -> RunReport + Sync,
|
|
{
|
|
let space = self.bp.param_space();
|
|
check_param_namespace_injective(&space).map_err(BindError::Compile)?;
|
|
let ordered = resolve_ranges(&space, &self.ranges)?;
|
|
let rs = RandomSpace::new(&space, ordered, count, seed)
|
|
.expect("named layer pre-validates arity/kind/non-empty");
|
|
Ok(sweep(&rs, run_one))
|
|
}
|
|
}
|
|
|
|
/// The shared two-phase named-binding resolution against `param_space()`, generic
|
|
/// over the per-slot payload `T` (one `Scalar` for [`resolve`], a `Vec<Scalar>`
|
|
/// axis for [`resolve_axes`]). The two callers differ only in the per-element work
|
|
/// at two pinned points; the error TOTAL ORDER lives here, once, so it cannot drift
|
|
/// between the scalar and axis paths (the #45/#53 "two raise-sites for one
|
|
/// invariant" hazard).
|
|
///
|
|
/// `claim_ok` runs inside the phase-1 `[idx]` arm BEFORE the duplicate check (an
|
|
/// axis rejects an empty value list there); `kind_ok` is the phase-2 per-slot kind
|
|
/// gate (an axis loops every element, first offender in axis order winning). Both
|
|
/// return `Some(err)` to abort with that error, `None` to continue. The order of
|
|
/// arms — phase 1 per input `UnknownKnob → claim_ok → DuplicateBinding`, then phase
|
|
/// 2 per slot `MissingKnob → kind_ok` — is the contract pinned by the resolve/bind
|
|
/// tests; do not reorder.
|
|
fn resolve_into<T: Clone>(
|
|
space: &[ParamSpec],
|
|
bindings: &[(String, T)],
|
|
claim_ok: impl Fn(&str, &T) -> Option<BindError>,
|
|
kind_ok: impl Fn(&ParamSpec, &T) -> Option<BindError>,
|
|
) -> Result<Vec<T>, BindError> {
|
|
// Phase 1 — per binding in input order: claim slots by exact name.
|
|
let mut claimed: Vec<Option<T>> = vec![None; space.len()];
|
|
for (name, value) in bindings {
|
|
let matches: Vec<usize> = space
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, p)| p.name == *name)
|
|
.map(|(i, _)| i)
|
|
.collect();
|
|
match matches.as_slice() {
|
|
[] => return Err(BindError::UnknownKnob(name.clone())), // a
|
|
[idx] => {
|
|
if let Some(err) = claim_ok(name, value) {
|
|
return Err(err); // c (axis-only: EmptyAxis), before duplicate
|
|
}
|
|
if claimed[*idx].is_some() {
|
|
return Err(BindError::DuplicateBinding(name.clone())); // d
|
|
}
|
|
claimed[*idx] = Some(value.clone());
|
|
}
|
|
_ => unreachable!("param_space() is injective — checked before resolve"),
|
|
}
|
|
}
|
|
// Phase 2 — slot walk in param_space() order: completeness (e) + kind (f).
|
|
let mut ordered = Vec::with_capacity(space.len());
|
|
for (i, p) in space.iter().enumerate() {
|
|
match &claimed[i] {
|
|
None => return Err(BindError::MissingKnob(p.name.clone())), // e
|
|
Some(value) => {
|
|
if let Some(err) = kind_ok(p, value) {
|
|
return Err(err); // f
|
|
}
|
|
ordered.push(value.clone());
|
|
}
|
|
}
|
|
}
|
|
Ok(ordered)
|
|
}
|
|
|
|
/// The phase-2 kind gate for a single scalar: mismatch against the slot kind.
|
|
fn scalar_kind_err(p: &ParamSpec, v: Scalar) -> Option<BindError> {
|
|
(v.kind() != p.kind).then(|| BindError::KindMismatch {
|
|
knob: p.name.clone(),
|
|
expected: p.kind,
|
|
got: v.kind(),
|
|
})
|
|
}
|
|
|
|
/// Resolve named sweep axes to a positional `Vec<Vec<Scalar>>` in `param_space()`
|
|
/// slot order. Thin caller over [`resolve_into`]: an axis rejects an empty value
|
|
/// list at claim time (`EmptyAxis`, before the duplicate check) and kind-checks
|
|
/// every element (first offender in axis order).
|
|
fn resolve_axes(space: &[ParamSpec], axes: &[(String, Vec<Scalar>)]) -> Result<Vec<Vec<Scalar>>, BindError> {
|
|
resolve_into(
|
|
space,
|
|
axes,
|
|
|name, values| values.is_empty().then(|| BindError::EmptyAxis(name.to_string())),
|
|
|p, values| values.iter().find_map(|v| scalar_kind_err(p, *v)),
|
|
)
|
|
}
|
|
|
|
/// Resolve named random-sweep ranges to a positional `Vec<ParamRange>` in
|
|
/// `param_space()` slot order. Thin caller over [`resolve_into`]: a range rejects an
|
|
/// empty interval at claim time (`EmptyRange`, before the duplicate check) and
|
|
/// kind-checks the range against the slot's declared kind. A non-numeric slot needs
|
|
/// no separate check — a `ParamRange` is always I64/F64, so `kind_ok` rejects it as a
|
|
/// `KindMismatch` before `RandomSpace::new` (whose `NonNumericRange` gate) is reached.
|
|
fn resolve_ranges(space: &[ParamSpec], ranges: &[(String, ParamRange)]) -> Result<Vec<ParamRange>, BindError> {
|
|
resolve_into(
|
|
space,
|
|
ranges,
|
|
|name, r: &ParamRange| r.is_empty().then(|| BindError::EmptyRange(name.to_string())),
|
|
|p, r: &ParamRange| (r.kind() != p.kind).then(|| BindError::KindMismatch {
|
|
knob: p.name.clone(),
|
|
expected: p.kind,
|
|
got: r.kind(),
|
|
}),
|
|
)
|
|
}
|
|
|
|
/// Structural validation (param-value-independent): the `param_space()` name
|
|
/// projection is the by-name knob address space (C12/C19) and must be injective —
|
|
/// a duplicated path is a knob no binding can select alone. The first duplicate in
|
|
/// `param_space()` order is reported (the order is deterministic). Single source of
|
|
/// duplicate detection; called from `compile_with_params` and from both binders
|
|
/// before name resolution.
|
|
pub(crate) fn check_param_namespace_injective(space: &[ParamSpec]) -> Result<(), CompileError> {
|
|
let mut seen = std::collections::HashSet::new();
|
|
for p in space {
|
|
if !seen.insert(p.name.as_str()) {
|
|
return Err(CompileError::DuplicateParamPath(p.name.clone()));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// §A predicate: structural validity of a gang table against its composite
|
|
/// frame's nodes. Shared by every minting boundary; gang-name collisions with
|
|
/// live addresses are NOT re-checked here — the projected `param_space()`
|
|
/// flows into `check_param_namespace_injective` on every compile/finish path.
|
|
pub(crate) fn check_gangs(nodes: &[BlueprintNode], gangs: &[Gang]) -> Result<(), CompileError> {
|
|
let mut claimed = std::collections::HashSet::new();
|
|
for g in gangs {
|
|
if g.name.is_empty() || g.name.contains('.') {
|
|
return Err(CompileError::BadGang(GangFault::BadName { gang: g.name.clone() }));
|
|
}
|
|
if g.members.len() < 2 {
|
|
return Err(CompileError::BadGang(GangFault::TooFewMembers { gang: g.name.clone() }));
|
|
}
|
|
for m in &g.members {
|
|
let item = nodes.get(m.node).ok_or_else(|| {
|
|
CompileError::BadGang(GangFault::NodeOutOfRange { gang: g.name.clone(), node: m.node })
|
|
})?;
|
|
let BlueprintNode::Primitive(b) = item else {
|
|
return Err(CompileError::BadGang(GangFault::NotAPrimitive {
|
|
gang: g.name.clone(),
|
|
node: m.node,
|
|
}));
|
|
};
|
|
let hits: Vec<usize> = b
|
|
.params()
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, p)| p.name == m.name)
|
|
.map(|(i, _)| i)
|
|
.collect();
|
|
let [idx] = hits.as_slice() else {
|
|
return Err(CompileError::BadGang(GangFault::NoOpenParam {
|
|
gang: g.name.clone(),
|
|
node: m.node,
|
|
name: m.name.clone(),
|
|
}));
|
|
};
|
|
let expected_pos = b.original_pos(*idx);
|
|
if expected_pos != m.pos {
|
|
return Err(CompileError::BadGang(GangFault::PosMismatch {
|
|
gang: g.name.clone(),
|
|
node: m.node,
|
|
expected: expected_pos,
|
|
got: m.pos,
|
|
}));
|
|
}
|
|
let member_kind = b.params()[*idx].kind;
|
|
if member_kind != g.kind {
|
|
return Err(CompileError::BadGang(GangFault::KindMismatch {
|
|
gang: g.name.clone(),
|
|
node: m.node,
|
|
name: m.name.clone(),
|
|
expected: g.kind,
|
|
got: member_kind,
|
|
}));
|
|
}
|
|
if !claimed.insert((m.node, m.pos)) {
|
|
return Err(CompileError::BadGang(GangFault::MemberInTwoGangs {
|
|
gang: g.name.clone(),
|
|
node: m.node,
|
|
pos: m.pos,
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Every root input role must be source-bound (`source: Some`): an open role
|
|
/// (`None`) at the root has no enclosing graph to wire it, so only a fully
|
|
/// source-bound composite is runnable. Shared by `compile_with_cells` (the
|
|
/// cell-side compile base) and `GraphSession::finish` (the op-script finalize),
|
|
/// so the root-role gate has a single definition across both cadences — the last
|
|
/// holistic check to be deduplicated (cycle 0088 audit).
|
|
pub(crate) fn check_root_roles_bound(roles: &[Role]) -> Result<(), CompileError> {
|
|
for (r, role) in roles.iter().enumerate() {
|
|
if role.source.is_none() {
|
|
return Err(CompileError::UnboundRootRole { role: r });
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Resolve named bindings to a positional `Vec<Scalar>` in `param_space()` slot
|
|
/// order. Thin caller over [`resolve_into`]: a single scalar has no claim-time
|
|
/// rejection and kind-checks the one value.
|
|
fn resolve(space: &[ParamSpec], bound: &[(String, Scalar)]) -> Result<Vec<Scalar>, BindError> {
|
|
resolve_into(
|
|
space,
|
|
bound,
|
|
|_name, _value| None,
|
|
|p, value| scalar_kind_err(p, *value),
|
|
)
|
|
}
|
|
|
|
/// A construction-phase fault, caught before the flat graph reaches
|
|
/// `Harness::bootstrap`.
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub enum CompileError {
|
|
/// An interior edge, role target, or output index is out of range.
|
|
BadInteriorIndex,
|
|
/// Input role `role` fans into interior slots of differing scalar kinds.
|
|
RoleKindMismatch { role: usize },
|
|
/// The output port names a missing interior node or output field.
|
|
OutputPortOutOfRange,
|
|
/// The lowered flat graph failed `Harness::bootstrap`'s checks (kind
|
|
/// mismatch, bad index, or directed cycle).
|
|
Bootstrap(BootstrapError),
|
|
/// An injected param value's scalar kind does not match the slot's declared
|
|
/// kind. `slot` is the flat param-space index.
|
|
ParamKindMismatch { slot: usize, expected: ScalarKind, got: ScalarKind },
|
|
/// The injected vector's length does not equal the sum of declared params.
|
|
ParamArity { expected: usize, got: usize },
|
|
/// Two `param_space()` slots resolved to the same path-qualified name — the
|
|
/// by-name knob address space (C12/C19) is not injective, so no binding can
|
|
/// select one slot without the other. Carries the duplicated path. Cure: give
|
|
/// the colliding same-type sibling nodes distinct names with `.named(...)`.
|
|
DuplicateParamPath(String),
|
|
/// A root input role `role` has no bound source (`source: None`) — an open port
|
|
/// at the root, which has no enclosing graph to wire it. Only a fully source-
|
|
/// bound composite is runnable.
|
|
UnboundRootRole { role: usize },
|
|
/// An interior node's input `slot` is covered by no edge and no role target — a
|
|
/// required port left unconnected (it would bootstrap a silent empty column).
|
|
UnconnectedPort { node: usize, slot: usize },
|
|
/// An interior node's input `slot` is covered by more than one edge/role target
|
|
/// combined — a slot holds exactly one column, so >1 producer is ill-formed.
|
|
DoubleWiredPort { node: usize, slot: usize },
|
|
/// A gang table failed structural validation at a minting boundary.
|
|
BadGang(GangFault),
|
|
}
|
|
|
|
/// The typed detail of a `CompileError::BadGang` (one arm per `check_gangs`
|
|
/// refusal).
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum GangFault {
|
|
TooFewMembers { gang: String },
|
|
BadName { gang: String },
|
|
NodeOutOfRange { gang: String, node: usize },
|
|
NotAPrimitive { gang: String, node: usize },
|
|
NoOpenParam { gang: String, node: usize, name: String },
|
|
PosMismatch { gang: String, node: usize, expected: usize, got: usize },
|
|
KindMismatch { gang: String, node: usize, name: String, expected: ScalarKind, got: ScalarKind },
|
|
MemberInTwoGangs { gang: String, node: usize, pos: usize },
|
|
}
|
|
|
|
/// The per-edge kind predicate, shared by `validate_wiring` (holistic) and the
|
|
/// eager `connect` op (`construction.rs`) — one check, two cadences (no second
|
|
/// validator). Looks the producer field + consumer slot up by index and rejects
|
|
/// a kind mismatch with the SAME variant bootstrap uses, so existing
|
|
/// compiled-graph tests stay green.
|
|
pub(crate) fn edge_kind_check(
|
|
from: &NodeSchema,
|
|
from_field: usize,
|
|
to: &NodeSchema,
|
|
slot: usize,
|
|
) -> Result<(), CompileError> {
|
|
let f = from.output.get(from_field).ok_or(CompileError::BadInteriorIndex)?;
|
|
let s = to.inputs.get(slot).ok_or(CompileError::BadInteriorIndex)?;
|
|
if f.kind != s.kind {
|
|
return Err(CompileError::Bootstrap(BootstrapError::KindMismatch {
|
|
producer: f.kind,
|
|
consumer: s.kind,
|
|
}));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Pre-build structural validation via `signature()` (no node constructed): every
|
|
/// edge's producer field and consumer slot are in range and kind-matched; every
|
|
/// output re-export and role target is in range and kind-consistent. Recurses into
|
|
/// nested composites so the checks hold at every level. This is what lets `compile`
|
|
/// reject a wiring fault before any build closure fires.
|
|
pub(crate) fn validate_wiring(
|
|
nodes: &[BlueprintNode],
|
|
edges: &[Edge],
|
|
roles: &[Role],
|
|
output: &[OutField],
|
|
) -> Result<(), CompileError> {
|
|
// edges: index-range + producer/consumer kind match. The kind-mismatch variant
|
|
// is the SAME one bootstrap returns today (Bootstrap(KindMismatch)), just raised
|
|
// pre-build — so existing tests asserting that variant for a compiled graph stay
|
|
// green, while the fault is now caught before any build closure fires.
|
|
for e in edges {
|
|
let from = nodes.get(e.from).ok_or(CompileError::BadInteriorIndex)?.signature();
|
|
let to = nodes.get(e.to).ok_or(CompileError::BadInteriorIndex)?.signature();
|
|
edge_kind_check(&from, e.from_field, &to, e.slot)?;
|
|
}
|
|
// roles: every target in range, and all targets of one role share a kind
|
|
// (RoleKindMismatch — the existing variant, today read off built schema()).
|
|
for (r, role) in roles.iter().enumerate() {
|
|
let mut role_kind: Option<ScalarKind> = None;
|
|
for t in &role.targets {
|
|
let sig = nodes.get(t.node).ok_or(CompileError::BadInteriorIndex)?.signature();
|
|
let k = sig.inputs.get(t.slot).ok_or(CompileError::BadInteriorIndex)?.kind;
|
|
match role_kind {
|
|
None => role_kind = Some(k),
|
|
Some(k0) if k0 != k => return Err(CompileError::RoleKindMismatch { role: r }),
|
|
Some(_) => {}
|
|
}
|
|
}
|
|
}
|
|
// outputs: each re-export's field index in range
|
|
for of in output {
|
|
let sig = nodes.get(of.node).ok_or(CompileError::OutputPortOutOfRange)?.signature();
|
|
if of.field >= sig.output.len() {
|
|
return Err(CompileError::OutputPortOutOfRange);
|
|
}
|
|
}
|
|
// wiring totality: every interior input slot covered by exactly one wiring act
|
|
check_ports_connected(nodes, edges, roles)?;
|
|
// recurse into nested composites
|
|
for item in nodes {
|
|
if let BlueprintNode::Composite(c) = item {
|
|
validate_wiring(c.nodes(), c.edges(), c.input_roles(), c.output())?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Every interior node's every declared input slot must be covered by exactly one
|
|
/// wiring act — one interior edge OR one role target, counted uniformly. Zero = a
|
|
/// forgotten connection (would bootstrap a silent empty column); >1 = an ill-formed
|
|
/// slot (a slot holds one column). Index-based, name-free; presupposes in-range
|
|
/// edge/role indices (runs after the existing index-range checks). Mirrors the
|
|
/// single-site shape of `check_param_namespace_injective` (the wiring-side sibling).
|
|
fn check_ports_connected(
|
|
nodes: &[BlueprintNode],
|
|
edges: &[Edge],
|
|
roles: &[Role],
|
|
) -> Result<(), CompileError> {
|
|
let mut coverage: std::collections::HashMap<(usize, usize), usize> =
|
|
std::collections::HashMap::new();
|
|
for e in edges {
|
|
*coverage.entry((e.to, e.slot)).or_insert(0) += 1;
|
|
}
|
|
for role in roles {
|
|
for t in &role.targets {
|
|
*coverage.entry((t.node, t.slot)).or_insert(0) += 1;
|
|
}
|
|
}
|
|
for (n, item) in nodes.iter().enumerate() {
|
|
for slot in 0..item.signature().inputs.len() {
|
|
match coverage.get(&(n, slot)).copied().unwrap_or(0) {
|
|
1 => {}
|
|
0 => return Err(CompileError::UnconnectedPort { node: n, slot }),
|
|
_ => return Err(CompileError::DoubleWiredPort { node: n, slot }),
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Recursive read-only walk for `Blueprint::param_space`: a leaf contributes its
|
|
/// declared params under `<prefix>.<node-name>.<param>` (the node name is the
|
|
/// instance name, default = the lowercased type label); a composite pushes its
|
|
/// `name()` onto the path and recurses. Order mirrors `lower_items` (items in
|
|
/// declared order, composites depth-first) so a param's slot matches the later
|
|
/// flat-node order.
|
|
///
|
|
/// Gang-aware (#61 task 2): `gangs` is the CURRENT frame's gang table (keyed by
|
|
/// `(item index within `items`, original pre-bind pos)`). A ganged member is
|
|
/// skipped at its own address; the gang's single public address is emitted once,
|
|
/// at its FIRST member's raw position, prefixed like any other address at this
|
|
/// frame. Un-ganged params are untouched.
|
|
fn collect_params(items: &[BlueprintNode], gangs: &[Gang], prefix: &str, out: &mut Vec<ParamSpec>) {
|
|
let mut ganged = std::collections::HashMap::new();
|
|
for (gi, g) in gangs.iter().enumerate() {
|
|
for m in &g.members {
|
|
ganged.insert((m.node, m.pos), gi);
|
|
}
|
|
}
|
|
let mut emitted = vec![false; gangs.len()];
|
|
for (ni, item) in items.iter().enumerate() {
|
|
match item {
|
|
BlueprintNode::Primitive(b) => {
|
|
let node = if prefix.is_empty() {
|
|
b.node_name()
|
|
} else {
|
|
format!("{prefix}.{}", b.node_name())
|
|
};
|
|
for (i, p) in b.params().iter().enumerate() {
|
|
if let Some(&gi) = ganged.get(&(ni, b.original_pos(i))) {
|
|
if !emitted[gi] {
|
|
emitted[gi] = true;
|
|
let g = &gangs[gi];
|
|
let name = if prefix.is_empty() {
|
|
g.name.clone()
|
|
} else {
|
|
format!("{prefix}.{}", g.name)
|
|
};
|
|
out.push(ParamSpec { name, kind: g.kind });
|
|
}
|
|
continue;
|
|
}
|
|
out.push(ParamSpec { name: format!("{node}.{}", p.name), kind: p.kind });
|
|
}
|
|
}
|
|
BlueprintNode::Composite(c) => {
|
|
let child = if prefix.is_empty() {
|
|
c.name().to_string()
|
|
} else {
|
|
format!("{prefix}.{}", c.name())
|
|
};
|
|
collect_params(c.nodes(), c.gangs(), &child, out);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// For each raw open param slot (the `lower_items` cursor order), the index of
|
|
/// the public-space entry (`param_space()`/`collect_params` order) that supplies
|
|
/// its cell (#61 task 2). Un-ganged slots consume the next public index in
|
|
/// order; a gang's first member claims the gang's public index and every later
|
|
/// member repeats it. Mirrors `collect_params`'s walk exactly (same recursion
|
|
/// shape, same `ganged`/`emitted` bookkeeping) so the two walks share their
|
|
/// order by construction — a gang-free blueprint yields the identity map
|
|
/// `[0, 1, 2, ...]`.
|
|
fn expansion_map(items: &[BlueprintNode], gangs: &[Gang], next_public: &mut usize, map: &mut Vec<usize>) {
|
|
let mut ganged = std::collections::HashMap::new();
|
|
for (gi, g) in gangs.iter().enumerate() {
|
|
for m in &g.members {
|
|
ganged.insert((m.node, m.pos), gi);
|
|
}
|
|
}
|
|
let mut gang_public: Vec<Option<usize>> = vec![None; gangs.len()];
|
|
for (ni, item) in items.iter().enumerate() {
|
|
match item {
|
|
BlueprintNode::Primitive(b) => {
|
|
for i in 0..b.params().len() {
|
|
if let Some(&gi) = ganged.get(&(ni, b.original_pos(i))) {
|
|
let idx = *gang_public[gi].get_or_insert_with(|| {
|
|
let idx = *next_public;
|
|
*next_public += 1;
|
|
idx
|
|
});
|
|
map.push(idx);
|
|
} else {
|
|
map.push(*next_public);
|
|
*next_public += 1;
|
|
}
|
|
}
|
|
}
|
|
BlueprintNode::Composite(c) => {
|
|
expansion_map(c.nodes(), c.gangs(), next_public, map);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// How one blueprint item resolved into the flat graph. Edges and source
|
|
/// targets to/from an item are resolved through this.
|
|
enum ItemLowering {
|
|
/// A leaf lowered to exactly one flat node at this index.
|
|
Leaf { index: usize },
|
|
/// A composite lowered to its interior: its output record is these flat
|
|
/// `(node, field)` producers (one per re-exported field, declared order), and
|
|
/// input role `r` fans into `roles[r]` (flat targets). Names dropped (C23).
|
|
Composite { output: Vec<(usize, usize)>, roles: Vec<Vec<Target>> },
|
|
}
|
|
|
|
/// Lower a list of blueprint items into the flat node array, appending interior
|
|
/// nodes and (for composites) their interior edges. Returns one `ItemLowering` per
|
|
/// input item, in order.
|
|
fn lower_items(
|
|
items: Vec<BlueprintNode>,
|
|
point: &[Cell],
|
|
cursor: &mut usize,
|
|
flat_nodes: &mut Vec<Box<dyn Node>>,
|
|
flat_signatures: &mut Vec<NodeSchema>,
|
|
flat_edges: &mut Vec<Edge>,
|
|
) -> Result<Vec<ItemLowering>, CompileError> {
|
|
let mut lowerings = Vec::with_capacity(items.len());
|
|
for item in items {
|
|
match item {
|
|
BlueprintNode::Primitive(builder) => {
|
|
let n = builder.params().len();
|
|
// cells are trusted: kind-checked at the authoring edge (the
|
|
// `compile_with_params` frontend); here read each by its declared kind.
|
|
let slice = &point[*cursor..*cursor + n]; // in range: arity checked up front
|
|
let index = flat_nodes.len();
|
|
flat_signatures.push(builder.schema().clone());
|
|
flat_nodes.push(builder.build(slice));
|
|
*cursor += n;
|
|
lowerings.push(ItemLowering::Leaf { index });
|
|
}
|
|
BlueprintNode::Composite(c) => {
|
|
lowerings.push(inline_composite(
|
|
c,
|
|
point,
|
|
cursor,
|
|
flat_nodes,
|
|
flat_signatures,
|
|
flat_edges,
|
|
)?);
|
|
}
|
|
}
|
|
}
|
|
Ok(lowerings)
|
|
}
|
|
|
|
/// Inline one composite: recursively lower its interior items, rewrite its interior
|
|
/// edges, then resolve its output port and per-role flat targets.
|
|
fn inline_composite(
|
|
c: Composite,
|
|
point: &[Cell],
|
|
cursor: &mut usize,
|
|
flat_nodes: &mut Vec<Box<dyn Node>>,
|
|
flat_signatures: &mut Vec<NodeSchema>,
|
|
flat_edges: &mut Vec<Edge>,
|
|
) -> Result<ItemLowering, CompileError> {
|
|
// `name` is the non-load-bearing render symbol (#13); it dissolves at inline
|
|
// (C23 — the boundary does not reach the flat graph), so it is not destructured.
|
|
// Node names join the same non-load-bearing debug-symbol class: they qualify the
|
|
// param-space path at construction but are dropped at lowering — the injected
|
|
// cell `point: &[Cell]` arg drives `lower_items` below; the flat graph stays
|
|
// wired by raw index.
|
|
// `gangs` is an authoring-time gate only (checked at `with_gangs`); it has
|
|
// no runtime representation in the flat graph, same as `name`. `doc` is
|
|
// the prose twin of `name` (#125) and dissolves alongside it.
|
|
let Composite { name: _, doc: _, nodes, edges, input_roles, output, gangs: _ } = c;
|
|
let item_count = nodes.len();
|
|
|
|
// recursively lower interior items, then rewrite interior edges through them
|
|
let interior = lower_items(nodes, point, cursor, flat_nodes, flat_signatures, flat_edges)?;
|
|
for e in &edges {
|
|
for fe in rewrite_edge(e, &interior, flat_signatures)? {
|
|
flat_edges.push(fe);
|
|
}
|
|
}
|
|
|
|
// resolve each re-exported field to a flat (node, field), in declared order
|
|
let mut out: Vec<(usize, usize)> = Vec::with_capacity(output.len());
|
|
for of in &output {
|
|
if of.node >= item_count {
|
|
return Err(CompileError::OutputPortOutOfRange);
|
|
}
|
|
let resolved = match &interior[of.node] {
|
|
ItemLowering::Leaf { index } => {
|
|
if of.field >= flat_signatures[*index].output.len() {
|
|
return Err(CompileError::OutputPortOutOfRange);
|
|
}
|
|
(*index, of.field)
|
|
}
|
|
ItemLowering::Composite { output: nested, .. } => {
|
|
*nested.get(of.field).ok_or(CompileError::OutputPortOutOfRange)?
|
|
}
|
|
};
|
|
out.push(resolved);
|
|
}
|
|
|
|
// resolve each input role to flat targets (a target into a nested composite
|
|
// fans further) and kind-check every role
|
|
let mut roles: Vec<Vec<Target>> = Vec::with_capacity(input_roles.len());
|
|
for (r, role) in input_roles.iter().enumerate() {
|
|
let mut flat_targets: Vec<Target> = Vec::new();
|
|
for t in &role.targets {
|
|
flat_targets.extend(resolve_target(t, &interior)?);
|
|
}
|
|
if let Some((first, rest)) = flat_targets.split_first() {
|
|
let k0 = slot_kind(*first, flat_signatures)?;
|
|
for ft in rest {
|
|
if slot_kind(*ft, flat_signatures)? != k0 {
|
|
return Err(CompileError::RoleKindMismatch { role: r });
|
|
}
|
|
}
|
|
}
|
|
roles.push(flat_targets);
|
|
}
|
|
|
|
Ok(ItemLowering::Composite { output: out, roles })
|
|
}
|
|
|
|
/// Rewrite one blueprint-level edge into flat edges. The `from` endpoint resolves
|
|
/// to a single flat producer `(node, field)`; the `to` endpoint may fan out (a
|
|
/// composite input role fans into several interior targets).
|
|
fn rewrite_edge(
|
|
e: &Edge,
|
|
lowerings: &[ItemLowering],
|
|
flat_signatures: &[NodeSchema],
|
|
) -> Result<Vec<Edge>, CompileError> {
|
|
if e.from >= lowerings.len() {
|
|
return Err(CompileError::BadInteriorIndex);
|
|
}
|
|
let (from_node, from_field) = match &lowerings[e.from] {
|
|
ItemLowering::Leaf { index } => {
|
|
if e.from_field >= flat_signatures[*index].output.len() {
|
|
return Err(CompileError::BadInteriorIndex);
|
|
}
|
|
(*index, e.from_field)
|
|
}
|
|
ItemLowering::Composite { output, .. } => {
|
|
*output.get(e.from_field).ok_or(CompileError::BadInteriorIndex)?
|
|
}
|
|
};
|
|
let targets = resolve_target(&Target { node: e.to, slot: e.slot }, lowerings)?;
|
|
Ok(targets
|
|
.into_iter()
|
|
.map(|t| Edge { from: from_node, to: t.node, slot: t.slot, from_field })
|
|
.collect())
|
|
}
|
|
|
|
/// Resolve a blueprint-level target `(node, slot)` into flat target(s). A target
|
|
/// into a leaf is itself (remapped index); a target into a composite fans into
|
|
/// that composite's input-role flat targets.
|
|
fn resolve_target(t: &Target, lowerings: &[ItemLowering]) -> Result<Vec<Target>, CompileError> {
|
|
if t.node >= lowerings.len() {
|
|
return Err(CompileError::BadInteriorIndex);
|
|
}
|
|
match &lowerings[t.node] {
|
|
ItemLowering::Leaf { index } => Ok(vec![Target { node: *index, slot: t.slot }]),
|
|
ItemLowering::Composite { roles, .. } => {
|
|
let role = roles.get(t.slot).ok_or(CompileError::BadInteriorIndex)?;
|
|
Ok(role.clone())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The declared scalar kind of a flat node's input slot (for role kind-checking).
|
|
fn slot_kind(t: Target, flat_signatures: &[NodeSchema]) -> Result<ScalarKind, CompileError> {
|
|
flat_signatures[t.node]
|
|
.inputs
|
|
.get(t.slot)
|
|
.map(|spec| spec.kind)
|
|
.ok_or(CompileError::BadInteriorIndex)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::test_fixtures::{composite_sma_cross_harness, synthetic_prices};
|
|
use crate::{f64_field, summarize, ParamRange, RandomSpace, RunManifest, VecSource};
|
|
use aura_core::{Cell, Ctx, FieldSpec, Firing, NodeSchema, Timestamp};
|
|
use aura_std::{Bias, Ema, LinComb, Recorder, SimBroker, Sma, Sub};
|
|
use std::sync::mpsc;
|
|
|
|
/// One knob fanning into two sibling open params passes the gate; the
|
|
/// value carries the gang table.
|
|
#[test]
|
|
fn with_gangs_accepts_two_open_siblings() {
|
|
let c = Composite::new(
|
|
"sig",
|
|
vec![
|
|
BlueprintNode::Primitive(Sma::builder().named("a")),
|
|
BlueprintNode::Primitive(Sma::builder().named("b")),
|
|
],
|
|
vec![],
|
|
vec![],
|
|
vec![],
|
|
);
|
|
let c = c
|
|
.with_gangs(vec![Gang {
|
|
name: "length".into(),
|
|
kind: ScalarKind::I64,
|
|
members: vec![
|
|
GangMember { node: 0, pos: 0, name: "length".into() },
|
|
GangMember { node: 1, pos: 0, name: "length".into() },
|
|
],
|
|
}])
|
|
.expect("two open siblings gang");
|
|
assert_eq!(c.gangs().len(), 1);
|
|
}
|
|
|
|
/// Every check_gangs refusal arm fires as a typed BadGang fault.
|
|
#[test]
|
|
fn with_gangs_refuses_each_malformed_table() {
|
|
let mk = || {
|
|
Composite::new(
|
|
"sig",
|
|
vec![
|
|
BlueprintNode::Primitive(Sma::builder().named("a")),
|
|
BlueprintNode::Primitive(Sma::builder().named("b")),
|
|
],
|
|
vec![],
|
|
vec![],
|
|
vec![],
|
|
)
|
|
};
|
|
let gang = |name: &str, members: Vec<GangMember>| Gang {
|
|
name: name.into(),
|
|
kind: ScalarKind::I64,
|
|
members,
|
|
};
|
|
let m = |node, pos, name: &str| GangMember { node, pos, name: name.into() };
|
|
// < 2 members
|
|
let e = mk().with_gangs(vec![gang("g", vec![m(0, 0, "length")])]).err().unwrap();
|
|
assert!(matches!(e, CompileError::BadGang(GangFault::TooFewMembers { .. })), "{e:?}");
|
|
// dotted name
|
|
let e = mk()
|
|
.with_gangs(vec![gang("a.b", vec![m(0, 0, "length"), m(1, 0, "length")])])
|
|
.err().unwrap();
|
|
assert!(matches!(e, CompileError::BadGang(GangFault::BadName { .. })), "{e:?}");
|
|
// node out of range
|
|
let e = mk()
|
|
.with_gangs(vec![gang("g", vec![m(0, 0, "length"), m(9, 0, "length")])])
|
|
.err().unwrap();
|
|
assert!(matches!(e, CompileError::BadGang(GangFault::NodeOutOfRange { .. })), "{e:?}");
|
|
// member node is a composite, not a primitive
|
|
let nested = Composite::new(
|
|
"sig",
|
|
vec![
|
|
BlueprintNode::Composite(Composite::new("inner", vec![], vec![], vec![], vec![])),
|
|
BlueprintNode::Primitive(Sma::builder().named("b")),
|
|
],
|
|
vec![],
|
|
vec![],
|
|
vec![],
|
|
);
|
|
let e = nested
|
|
.with_gangs(vec![gang("g", vec![m(0, 0, "length"), m(1, 0, "length")])])
|
|
.err().unwrap();
|
|
assert!(matches!(e, CompileError::BadGang(GangFault::NotAPrimitive { .. })), "{e:?}");
|
|
// no open param of that name (bound at authoring)
|
|
let bound = Composite::new(
|
|
"sig",
|
|
vec![
|
|
BlueprintNode::Primitive(Sma::builder().named("a").bind("length", Scalar::i64(2))),
|
|
BlueprintNode::Primitive(Sma::builder().named("b")),
|
|
],
|
|
vec![],
|
|
vec![],
|
|
vec![],
|
|
);
|
|
let e = bound
|
|
.with_gangs(vec![gang("g", vec![m(0, 0, "length"), m(1, 0, "length")])])
|
|
.err().unwrap();
|
|
assert!(matches!(e, CompileError::BadGang(GangFault::NoOpenParam { .. })), "{e:?}");
|
|
// kind mismatch (declared F64 over I64 members)
|
|
let e = mk()
|
|
.with_gangs(vec![Gang {
|
|
name: "g".into(),
|
|
kind: ScalarKind::F64,
|
|
members: vec![m(0, 0, "length"), m(1, 0, "length")],
|
|
}])
|
|
.err().unwrap();
|
|
assert!(matches!(e, CompileError::BadGang(GangFault::KindMismatch { .. })), "{e:?}");
|
|
// one param claimed twice
|
|
let e = mk()
|
|
.with_gangs(vec![
|
|
gang("g", vec![m(0, 0, "length"), m(1, 0, "length")]),
|
|
gang("h", vec![m(1, 0, "length"), m(0, 0, "length")]),
|
|
])
|
|
.err().unwrap();
|
|
assert!(matches!(e, CompileError::BadGang(GangFault::MemberInTwoGangs { .. })), "{e:?}");
|
|
// pos out of step with the schema
|
|
let e = mk()
|
|
.with_gangs(vec![gang("g", vec![m(0, 3, "length"), m(1, 0, "length")])])
|
|
.err().unwrap();
|
|
assert!(matches!(e, CompileError::BadGang(GangFault::PosMismatch { .. })), "{e:?}");
|
|
}
|
|
|
|
/// Two length-open SMAs fed from one bound price role, spread via `Sub`
|
|
/// (Task 1's fixture shape, root-bound so it compiles standalone), with
|
|
/// their `length` params fused into one gang.
|
|
fn ganged_pair() -> Composite {
|
|
Composite::new(
|
|
"sig",
|
|
vec![
|
|
Sma::builder().named("a").into(),
|
|
Sma::builder().named("b").into(),
|
|
Sub::builder().into(),
|
|
],
|
|
vec![
|
|
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
|
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
|
|
],
|
|
vec![Role {
|
|
name: "price".into(),
|
|
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
|
|
source: Some(ScalarKind::F64),
|
|
}],
|
|
vec![OutField { node: 2, field: 0, name: "out".into() }],
|
|
)
|
|
.with_gangs(vec![Gang {
|
|
name: "length".into(),
|
|
kind: ScalarKind::I64,
|
|
members: vec![
|
|
GangMember { node: 0, pos: 0, name: "length".into() },
|
|
GangMember { node: 1, pos: 0, name: "length".into() },
|
|
],
|
|
}])
|
|
.expect("two open siblings gang")
|
|
}
|
|
|
|
/// The un-ganged twin of [`ganged_pair`]: identical topology, both
|
|
/// `length`s stay independently open in `param_space()`.
|
|
fn unganged_pair() -> Composite {
|
|
Composite::new(
|
|
"sig",
|
|
vec![
|
|
Sma::builder().named("a").into(),
|
|
Sma::builder().named("b").into(),
|
|
Sub::builder().into(),
|
|
],
|
|
vec![
|
|
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
|
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
|
|
],
|
|
vec![Role {
|
|
name: "price".into(),
|
|
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
|
|
source: Some(ScalarKind::F64),
|
|
}],
|
|
vec![OutField { node: 2, field: 0, name: "out".into() }],
|
|
)
|
|
}
|
|
|
|
/// Four open SMAs, two disjoint gangs both named "length" — a gang-name
|
|
/// collision cannot arise from a bare node-path address (1 segment vs. 2),
|
|
/// so this is the only shape that can trip the projected space's
|
|
/// injectivity gate.
|
|
fn two_gangs_same_name() -> Composite {
|
|
Composite::new(
|
|
"sig",
|
|
vec![
|
|
Sma::builder().named("a").into(),
|
|
Sma::builder().named("b").into(),
|
|
Sma::builder().named("c").into(),
|
|
Sma::builder().named("d").into(),
|
|
],
|
|
vec![],
|
|
vec![],
|
|
vec![],
|
|
)
|
|
.with_gangs(vec![
|
|
Gang {
|
|
name: "length".into(),
|
|
kind: ScalarKind::I64,
|
|
members: vec![
|
|
GangMember { node: 0, pos: 0, name: "length".into() },
|
|
GangMember { node: 1, pos: 0, name: "length".into() },
|
|
],
|
|
},
|
|
Gang {
|
|
name: "length".into(),
|
|
kind: ScalarKind::I64,
|
|
members: vec![
|
|
GangMember { node: 2, pos: 0, name: "length".into() },
|
|
GangMember { node: 3, pos: 0, name: "length".into() },
|
|
],
|
|
},
|
|
])
|
|
.expect("two structurally-valid gangs, same name, disjoint members")
|
|
}
|
|
|
|
/// The projection: two ganged member addresses collapse to ONE gang
|
|
/// address, emitted at the FIRST member's raw position; un-ganged params
|
|
/// are untouched.
|
|
#[test]
|
|
fn param_space_projects_ganged_members_to_one_address() {
|
|
let c = ganged_pair();
|
|
let space = c.param_space();
|
|
let names: Vec<&str> = space.iter().map(|p| p.name.as_str()).collect();
|
|
assert_eq!(names, ["length"], "one public knob replaces a.length/b.length");
|
|
assert_eq!(space[0].kind, ScalarKind::I64);
|
|
}
|
|
|
|
/// A gang nested inside a composite child wraps with the child's prefix,
|
|
/// one segment SHORTER than a member address would be.
|
|
#[test]
|
|
fn nested_composite_gang_wraps_with_the_frame_prefix() {
|
|
let inner = ganged_pair(); // composite named "sig"
|
|
let outer = Composite::new(
|
|
"root",
|
|
vec![BlueprintNode::Composite(inner)],
|
|
vec![],
|
|
vec![],
|
|
vec![],
|
|
);
|
|
let names: Vec<String> = outer.param_space().into_iter().map(|p| p.name).collect();
|
|
assert_eq!(names, ["sig.length"]);
|
|
}
|
|
|
|
/// A gang named like a surviving address is caught by the EXISTING
|
|
/// injectivity gate on the projected space.
|
|
#[test]
|
|
fn gang_name_collision_trips_param_namespace_injectivity() {
|
|
let c = two_gangs_same_name();
|
|
let err = check_param_namespace_injective(&c.param_space()).unwrap_err();
|
|
assert!(matches!(err, CompileError::DuplicateParamPath(p) if p == "length"));
|
|
}
|
|
|
|
/// Wrap one signal composite under a recording root and run the synthetic
|
|
/// price fixture, mirroring `blueprint_serde`'s
|
|
/// `serialized_blueprint_runs_bit_identical_to_rust_built` run half.
|
|
fn run_pair(bp: Composite, params: &[Scalar]) -> Vec<(Timestamp, Vec<Scalar>)> {
|
|
let (tx, rx) = mpsc::channel();
|
|
let root = Composite::new(
|
|
"h",
|
|
vec![
|
|
BlueprintNode::Composite(bp),
|
|
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx).into(),
|
|
],
|
|
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
|
|
vec![Role {
|
|
name: "src".into(),
|
|
targets: vec![Target { node: 0, slot: 0 }],
|
|
source: Some(ScalarKind::F64),
|
|
}],
|
|
vec![],
|
|
);
|
|
let prices = crate::test_fixtures::synthetic_prices();
|
|
let mut h = root.bootstrap_with_params(params.to_vec()).expect("bootstraps");
|
|
h.run(vec![Box::new(VecSource::new(prices))]);
|
|
rx.try_iter().collect()
|
|
}
|
|
|
|
/// THE load-bearing equivalence: binding the gang to v grades exactly like
|
|
/// binding every member to v on the un-ganged twin — both the compiled
|
|
/// wiring and the run trace are identical.
|
|
#[test]
|
|
fn ganged_compile_equals_member_bound_twin() {
|
|
let ganged = ganged_pair();
|
|
let twin = unganged_pair();
|
|
let g = ganged.compile_with_params(&[Scalar::i64(4)]).expect("ganged compile");
|
|
let t = twin
|
|
.compile_with_params(&[Scalar::i64(4), Scalar::i64(4)])
|
|
.expect("twin compile");
|
|
assert_eq!(g.edges, t.edges, "identical wiring");
|
|
|
|
let g_trace = run_pair(ganged_pair(), &[Scalar::i64(4)]);
|
|
let t_trace = run_pair(unganged_pair(), &[Scalar::i64(4), Scalar::i64(4)]);
|
|
assert_eq!(g_trace, t_trace, "ganged run diverged from the member-bound twin");
|
|
assert!(!g_trace.is_empty());
|
|
}
|
|
|
|
/// Interleaved-gang fixture (architect [low] finding, cycle close): one gang
|
|
/// member sits at a NON-zero pos among its own node's open params
|
|
/// (`LinComb`'s `weights[1]`, with `weights[0]` still open and EARLIER in
|
|
/// walk order), the other member is a sibling node's sole param
|
|
/// (`Bias.scale`), and a THIRD node's independent param (`Sma.length`) walks
|
|
/// AFTER the gang — so `expansion_map`'s "un-ganged slot consumes the next
|
|
/// public index" branch must correctly shift past the gang in the MIDDLE of
|
|
/// the walk, not only at an edge of it.
|
|
fn ganged_interleave() -> Composite {
|
|
Composite::new(
|
|
"sig3",
|
|
vec![
|
|
LinComb::builder(2).into(),
|
|
Bias::builder().into(),
|
|
Sma::builder().named("c").into(),
|
|
],
|
|
vec![],
|
|
vec![Role {
|
|
name: "price".into(),
|
|
targets: vec![
|
|
Target { node: 0, slot: 0 },
|
|
Target { node: 0, slot: 1 },
|
|
Target { node: 1, slot: 0 },
|
|
Target { node: 2, slot: 0 },
|
|
],
|
|
source: Some(ScalarKind::F64),
|
|
}],
|
|
vec![
|
|
OutField { node: 0, field: 0, name: "lincomb_out".into() },
|
|
OutField { node: 1, field: 0, name: "bias_out".into() },
|
|
OutField { node: 2, field: 0, name: "sma_out".into() },
|
|
],
|
|
)
|
|
.with_gangs(vec![Gang {
|
|
name: "shared".into(),
|
|
kind: ScalarKind::F64,
|
|
members: vec![
|
|
GangMember { node: 0, pos: 1, name: "weights[1]".into() },
|
|
GangMember { node: 1, pos: 0, name: "scale".into() },
|
|
],
|
|
}])
|
|
.expect("weights[1] (pos 1, not 0) ganged with bias.scale, both F64")
|
|
}
|
|
|
|
/// The un-ganged twin of [`ganged_interleave`]: identical topology, all four
|
|
/// params (`lincomb.weights[0]`, `lincomb.weights[1]`, `bias.scale`,
|
|
/// `c.length`) stay independently open.
|
|
fn unganged_interleave() -> Composite {
|
|
Composite::new(
|
|
"sig3",
|
|
vec![
|
|
LinComb::builder(2).into(),
|
|
Bias::builder().into(),
|
|
Sma::builder().named("c").into(),
|
|
],
|
|
vec![],
|
|
vec![Role {
|
|
name: "price".into(),
|
|
targets: vec![
|
|
Target { node: 0, slot: 0 },
|
|
Target { node: 0, slot: 1 },
|
|
Target { node: 1, slot: 0 },
|
|
Target { node: 2, slot: 0 },
|
|
],
|
|
source: Some(ScalarKind::F64),
|
|
}],
|
|
vec![
|
|
OutField { node: 0, field: 0, name: "lincomb_out".into() },
|
|
OutField { node: 1, field: 0, name: "bias_out".into() },
|
|
OutField { node: 2, field: 0, name: "sma_out".into() },
|
|
],
|
|
)
|
|
}
|
|
|
|
/// Wrap [`ganged_interleave`]/[`unganged_interleave`] under a recording root
|
|
/// and run the synthetic price fixture, taping all THREE composite outputs
|
|
/// (one per node): topology here is param-value-invariant (`edges` equality
|
|
/// alone would not catch a mis-shifted index landing a value on the wrong
|
|
/// node), so the trace over every affected node is what actually detects it.
|
|
fn run_triplet(bp: Composite, params: &[Scalar]) -> Vec<(Timestamp, Vec<Scalar>)> {
|
|
let (tx, rx) = mpsc::channel();
|
|
let root = Composite::new(
|
|
"h3",
|
|
vec![
|
|
BlueprintNode::Composite(bp),
|
|
Recorder::builder(
|
|
vec![ScalarKind::F64, ScalarKind::F64, ScalarKind::F64],
|
|
Firing::Any,
|
|
tx,
|
|
)
|
|
.into(),
|
|
],
|
|
vec![
|
|
Edge { from: 0, to: 1, slot: 0, from_field: 0 },
|
|
Edge { from: 0, to: 1, slot: 1, from_field: 1 },
|
|
Edge { from: 0, to: 1, slot: 2, from_field: 2 },
|
|
],
|
|
vec![Role {
|
|
name: "src".into(),
|
|
targets: vec![Target { node: 0, slot: 0 }],
|
|
source: Some(ScalarKind::F64),
|
|
}],
|
|
vec![],
|
|
);
|
|
let prices = crate::test_fixtures::synthetic_prices();
|
|
let mut h = root.bootstrap_with_params(params.to_vec()).expect("bootstraps");
|
|
h.run(vec![Box::new(VecSource::new(prices))]);
|
|
rx.try_iter().collect()
|
|
}
|
|
|
|
/// THE property under test: `collect_params` (public `param_space` order) and
|
|
/// `expansion_map` (raw-slot -> public-index) mirror each other even when a
|
|
/// gang's first member is NOT its node's first open param and an independent
|
|
/// param on a LATER node follows the gang in walk order — the "shift-through"
|
|
/// branch (an un-ganged slot consuming the next public index around a gang)
|
|
/// fires in the MIDDLE of the walk, not only at an edge. Values are pairwise
|
|
/// distinct (7 / 4 / 3) so a mis-mapped index changes which node receives
|
|
/// which value, diverging the run trace. `c.length` stays small (3) so the
|
|
/// SMA warms up within the 7-row synthetic price fixture.
|
|
#[test]
|
|
fn ganged_interleave_shift_through_matches_member_bound_twin() {
|
|
let space = ganged_interleave().param_space();
|
|
let names: Vec<&str> = space.iter().map(|p| p.name.as_str()).collect();
|
|
let kinds: Vec<ScalarKind> = space.iter().map(|p| p.kind).collect();
|
|
assert_eq!(
|
|
names,
|
|
["lincomb.weights[0]", "shared", "c.length"],
|
|
"the gang publishes at its first member's walk position, flanked by \
|
|
an earlier independent param on the SAME node and a later \
|
|
independent param on a DIFFERENT node"
|
|
);
|
|
assert_eq!(kinds, [ScalarKind::F64, ScalarKind::F64, ScalarKind::I64]);
|
|
|
|
let ganged_point = [Scalar::f64(7.0), Scalar::f64(4.0), Scalar::i64(3)];
|
|
let twin_point = [Scalar::f64(7.0), Scalar::f64(4.0), Scalar::f64(4.0), Scalar::i64(3)];
|
|
|
|
let g = ganged_interleave().compile_with_params(&ganged_point).expect("ganged compile");
|
|
let t = unganged_interleave().compile_with_params(&twin_point).expect("twin compile");
|
|
assert_eq!(g.edges, t.edges, "identical wiring");
|
|
|
|
let g_trace = run_triplet(ganged_interleave(), &ganged_point);
|
|
let t_trace = run_triplet(unganged_interleave(), &twin_point);
|
|
assert_eq!(g_trace, t_trace, "ganged run diverged from the member-bound twin");
|
|
assert!(!g_trace.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn edge_kind_check_accepts_match_and_rejects_mismatch() {
|
|
use super::edge_kind_check;
|
|
use aura_core::{FieldSpec, NodeSchema, PortSpec, ScalarKind};
|
|
// producer: one f64 output field; consumer: slot 0 is f64, slot 1 is bool.
|
|
let from = NodeSchema {
|
|
inputs: vec![],
|
|
output: vec![FieldSpec { name: "v".into(), kind: ScalarKind::F64 }],
|
|
params: vec![],
|
|
};
|
|
let to = NodeSchema {
|
|
inputs: vec![
|
|
PortSpec { kind: ScalarKind::F64, firing: aura_core::Firing::Any, name: "a".into() },
|
|
PortSpec { kind: ScalarKind::Bool, firing: aura_core::Firing::Any, name: "b".into() },
|
|
],
|
|
output: vec![],
|
|
params: vec![],
|
|
};
|
|
assert!(edge_kind_check(&from, 0, &to, 0).is_ok());
|
|
assert_eq!(
|
|
edge_kind_check(&from, 0, &to, 1),
|
|
Err(CompileError::Bootstrap(BootstrapError::KindMismatch {
|
|
producer: ScalarKind::F64,
|
|
consumer: ScalarKind::Bool,
|
|
}))
|
|
);
|
|
}
|
|
|
|
/// Build + bootstrap + run + drain + summarize one swept point into a
|
|
/// `RunReport`, using a fresh harness per point (disjoint runs, C1). A free
|
|
/// `fn` (Copy + Sync) so it serves both as the `sweep`/binder closure and as a
|
|
/// direct reference. Deterministic, so the same point reproduces its report
|
|
/// exactly — that is what makes two families comparable for equality.
|
|
fn run_point(point: &[Cell]) -> RunReport {
|
|
let (bp, rx_eq, rx_ex) = composite_sma_cross_harness();
|
|
let mut h = bp
|
|
.bootstrap_with_cells(point)
|
|
.expect("enumerated/sampled points are pre-validated against the param-space");
|
|
h.run(vec![Box::new(VecSource::new(synthetic_prices()))]);
|
|
let equity = f64_field(&rx_eq.try_iter().collect::<Vec<_>>(), 0);
|
|
let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 0);
|
|
RunReport {
|
|
manifest: RunManifest {
|
|
commit: "test".to_string(),
|
|
params: Vec::new(),
|
|
window: (Timestamp(0), Timestamp(0)),
|
|
seed: 0,
|
|
broker: "test".to_string(),
|
|
selection: None,
|
|
instrument: None,
|
|
topology_hash: None,
|
|
project: None,
|
|
},
|
|
metrics: summarize(&equity, &exposure),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn sweep_with_lattice_surfaces_grid_radixes_in_param_space_order() {
|
|
let bp = composite_sma_cross_harness().0;
|
|
let (fam, lattice) = bp
|
|
.axis("sma_cross.fast.length", vec![Scalar::i64(2), Scalar::i64(3)]) // 2 values
|
|
.axis("sma_cross.slow.length", vec![Scalar::i64(4), Scalar::i64(5)]) // 2 values
|
|
.axis("bias.scale", vec![Scalar::f64(0.5)]) // 1 value
|
|
.sweep_with_lattice(run_point)
|
|
.expect("named binding resolves and runs");
|
|
assert_eq!(lattice, vec![2, 2, 1], "radixes in param_space slot order");
|
|
assert_eq!(lattice.iter().product::<usize>(), fam.points.len()); // 4
|
|
}
|
|
|
|
/// Property (the reason `RandomBinder` exists): building a random sweep **by
|
|
/// name** is order-independent and binds the same knob to the same range
|
|
/// regardless of `.range(...)` call order — exactly the safety the grid's
|
|
/// `SweepBinder` already gives. The two I64 slots `sma_cross.fast.length` and
|
|
/// `sma_cross.slow.length` are the **same-kind transposition** pair: swapping
|
|
/// their two positional `ParamRange`s passes `RandomSpace::new`'s positional
|
|
/// validation silently yet tunes the wrong knob over the wrong interval. By-name
|
|
/// resolution makes that structurally impossible. Pinned at the observable
|
|
/// `SweepFamily` boundary: the family built `.range(fast).range(slow)` equals
|
|
/// the one built transposed `.range(slow).range(fast)`, and both equal the
|
|
/// *correctly-ordered* positional `RandomSpace::new`. (`count`/`seed` are
|
|
/// `RandomSpace`'s extra inputs; same seed => seed-determined, comparable
|
|
/// points, C1.)
|
|
#[test]
|
|
fn random_binder_by_name_is_order_independent_and_equals_positional() {
|
|
let (count, seed) = (8usize, 0xC0FFEEu64);
|
|
let fast = ParamRange::i64(2, 3);
|
|
let slow = ParamRange::i64(4, 5);
|
|
let scale = ParamRange::f64(0.25, 1.5);
|
|
|
|
// by name, fast-then-slow
|
|
let in_order = composite_sma_cross_harness()
|
|
.0
|
|
.range("sma_cross.fast.length", fast)
|
|
.range("sma_cross.slow.length", slow)
|
|
.range("bias.scale", scale)
|
|
.sweep(count, seed, run_point)
|
|
.expect("named ranges resolve against param_space()");
|
|
|
|
// by name, slow-then-fast (the same-kind transposition that mis-binds the
|
|
// positional API) — must produce the identical family
|
|
let transposed = composite_sma_cross_harness()
|
|
.0
|
|
.range("sma_cross.slow.length", slow)
|
|
.range("sma_cross.fast.length", fast)
|
|
.range("bias.scale", scale)
|
|
.sweep(count, seed, run_point)
|
|
.expect("named ranges resolve regardless of call order");
|
|
|
|
// the correctly-ordered positional RandomSpace (param_space() slot order
|
|
// is [fast, slow, scale]) — the ground truth the by-name layer must match
|
|
let space = composite_sma_cross_harness().0.param_space();
|
|
let positional = sweep(
|
|
&RandomSpace::new(&space, vec![fast, slow, scale], count, seed)
|
|
.expect("correctly-ordered positional ranges validate"),
|
|
run_point,
|
|
);
|
|
|
|
assert_eq!(
|
|
in_order, transposed,
|
|
"by-name random sweep is order-independent (the same-kind transposition guard)",
|
|
);
|
|
assert_eq!(
|
|
in_order, positional,
|
|
"by-name random sweep equals the correctly-ordered positional RandomSpace",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_axes_named_equals_positional() {
|
|
// named axes resolve to the positional Vec<Vec<Scalar>> in slot order,
|
|
// order-independent on the binding side
|
|
let space = vec![
|
|
ParamSpec { name: "sma_cross.fast".into(), kind: ScalarKind::I64 },
|
|
ParamSpec { name: "sma_cross.slow".into(), kind: ScalarKind::I64 },
|
|
ParamSpec { name: "scale".into(), kind: ScalarKind::F64 },
|
|
];
|
|
let axes = vec![
|
|
("scale".to_string(), vec![Scalar::f64(0.5)]),
|
|
("sma_cross.fast".to_string(), vec![Scalar::i64(2), Scalar::i64(3)]),
|
|
("sma_cross.slow".to_string(), vec![Scalar::i64(4), Scalar::i64(5)]),
|
|
];
|
|
assert_eq!(
|
|
resolve_axes(&space, &axes),
|
|
Ok(vec![
|
|
vec![Scalar::i64(2), Scalar::i64(3)],
|
|
vec![Scalar::i64(4), Scalar::i64(5)],
|
|
vec![Scalar::f64(0.5)],
|
|
]),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn varying_axes_names_only_the_multi_value_axes() {
|
|
let bp = composite_sma_cross_harness().0;
|
|
let names: Vec<String> = bp.param_space().into_iter().map(|p| p.name).collect();
|
|
assert!(names.len() >= 3, "fixture must have >= 3 param slots: {names:?}");
|
|
// vary the first axis (>1 value), pin the other two (1 value each).
|
|
let binder = bp
|
|
.axis(names[0].as_str(), vec![Scalar::i64(2), Scalar::i64(3)])
|
|
.axis(names[1].as_str(), vec![Scalar::i64(4)])
|
|
.axis(names[2].as_str(), vec![Scalar::f64(0.5)]);
|
|
assert_eq!(binder.varying_axes(), vec![names[0].clone()]);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_axes_empty_axis() {
|
|
let space = vec![ParamSpec { name: "a".into(), kind: ScalarKind::I64 }];
|
|
assert_eq!(
|
|
resolve_axes(&space, &[("a".to_string(), vec![])]),
|
|
Err(BindError::EmptyAxis("a".to_string())),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_axes_missing_knob() {
|
|
let space = vec![
|
|
ParamSpec { name: "a".into(), kind: ScalarKind::I64 },
|
|
ParamSpec { name: "b".into(), kind: ScalarKind::I64 },
|
|
];
|
|
assert_eq!(
|
|
resolve_axes(&space, &[("a".to_string(), vec![Scalar::i64(1)])]),
|
|
Err(BindError::MissingKnob("b".to_string())),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_axes_per_element_kind_mismatch() {
|
|
// a MIXED-kind axis: the second element mismatches; per-element check must
|
|
// catch it (a first-element-only check would pass it through to a panic).
|
|
let space = vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }];
|
|
let axes = vec![("scale".to_string(), vec![Scalar::f64(0.5), Scalar::i64(1)])];
|
|
assert_eq!(
|
|
resolve_axes(&space, &axes),
|
|
Err(BindError::KindMismatch {
|
|
knob: "scale".to_string(),
|
|
expected: ScalarKind::F64,
|
|
got: ScalarKind::I64,
|
|
}),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn named_sweep_rejects_wrong_kind_axis_without_panic() {
|
|
// builder path: an F64 axis bound to an I64 slot is rejected as a clean
|
|
// BindError, never reaching GridSpace::new's .expect() — the run closure
|
|
// must not be invoked.
|
|
let (bp, _eq, _ex) = composite_sma_cross_harness();
|
|
let result = bp
|
|
.axis("sma_cross.fast.length", [2.0, 3.0]) // F64 values for the I64 slot
|
|
.axis("sma_cross.slow.length", [4])
|
|
.axis("bias.scale", [0.5])
|
|
.sweep(|_: &[Cell]| -> RunReport { panic!("axis pre-validation must reject before running") });
|
|
assert_eq!(
|
|
result,
|
|
Err(BindError::KindMismatch {
|
|
knob: "sma_cross.fast.length".to_string(),
|
|
expected: ScalarKind::I64,
|
|
got: ScalarKind::F64,
|
|
}),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn named_axes_grid_parity_with_positional() {
|
|
// named axes enumerate the SAME GridSpace points (same order) as the
|
|
// positional grid over the sample param-space.
|
|
let space = composite_sma_cross_harness().0.param_space();
|
|
let named = resolve_axes(
|
|
&space,
|
|
&[
|
|
("sma_cross.fast.length".to_string(), vec![Scalar::i64(2), Scalar::i64(3)]),
|
|
("sma_cross.slow.length".to_string(), vec![Scalar::i64(4), Scalar::i64(5)]),
|
|
("bias.scale".to_string(), vec![Scalar::f64(0.5)]),
|
|
],
|
|
)
|
|
.expect("named axes resolve");
|
|
let positional = vec![
|
|
vec![Scalar::i64(2), Scalar::i64(3)],
|
|
vec![Scalar::i64(4), Scalar::i64(5)],
|
|
vec![Scalar::f64(0.5)],
|
|
];
|
|
let named_pts = GridSpace::new(&space, named).expect("named grid").points();
|
|
let pos_pts = GridSpace::new(&space, positional).expect("positional grid").points();
|
|
assert_eq!(named_pts, pos_pts, "named and positional grids must enumerate identically");
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_named_equals_positional_vector() {
|
|
// order-independent: shuffled bindings resolve to slot order
|
|
let space = vec![
|
|
ParamSpec { name: "sma_cross.fast".into(), kind: ScalarKind::I64 },
|
|
ParamSpec { name: "sma_cross.slow".into(), kind: ScalarKind::I64 },
|
|
ParamSpec { name: "scale".into(), kind: ScalarKind::F64 },
|
|
];
|
|
let bound = vec![
|
|
("scale".to_string(), Scalar::f64(0.5)),
|
|
("sma_cross.fast".to_string(), Scalar::i64(2)),
|
|
("sma_cross.slow".to_string(), Scalar::i64(4)),
|
|
];
|
|
assert_eq!(
|
|
resolve(&space, &bound),
|
|
Ok(vec![Scalar::i64(2), Scalar::i64(4), Scalar::f64(0.5)]),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_unknown_knob() {
|
|
let space = vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }];
|
|
assert_eq!(
|
|
resolve(&space, &[("nope".to_string(), Scalar::f64(0.5))]),
|
|
Err(BindError::UnknownKnob("nope".to_string())),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_missing_knob() {
|
|
let space = vec![
|
|
ParamSpec { name: "a".into(), kind: ScalarKind::I64 },
|
|
ParamSpec { name: "b".into(), kind: ScalarKind::I64 },
|
|
];
|
|
assert_eq!(
|
|
resolve(&space, &[("a".to_string(), Scalar::i64(1))]),
|
|
Err(BindError::MissingKnob("b".to_string())),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_kind_mismatch() {
|
|
let space = vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }];
|
|
assert_eq!(
|
|
resolve(&space, &[("scale".to_string(), Scalar::i64(2))]),
|
|
Err(BindError::KindMismatch {
|
|
knob: "scale".to_string(),
|
|
expected: ScalarKind::F64,
|
|
got: ScalarKind::I64,
|
|
}),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_duplicate_binding() {
|
|
let space = vec![ParamSpec { name: "a".into(), kind: ScalarKind::I64 }];
|
|
assert_eq!(
|
|
resolve(
|
|
&space,
|
|
&[("a".to_string(), Scalar::i64(1)), ("a".to_string(), Scalar::i64(2))],
|
|
),
|
|
Err(BindError::DuplicateBinding("a".to_string())),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_precedence_unknown_before_kind_mismatch() {
|
|
// Phase-1 (unknown) wins over Phase-2 (kind mismatch)
|
|
let space = vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }];
|
|
let bound = vec![
|
|
("typo".to_string(), Scalar::i64(9)),
|
|
("scale".to_string(), Scalar::i64(2)),
|
|
];
|
|
assert_eq!(resolve(&space, &bound), Err(BindError::UnknownKnob("typo".to_string())));
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_precedence_unknown_before_duplicate() {
|
|
// intra-binding: check a (unknown) precedes check d (duplicate)
|
|
let space = vec![ParamSpec { name: "a".into(), kind: ScalarKind::I64 }];
|
|
let bound = vec![
|
|
("typo".to_string(), Scalar::i64(1)),
|
|
("typo".to_string(), Scalar::i64(2)),
|
|
];
|
|
assert_eq!(resolve(&space, &bound), Err(BindError::UnknownKnob("typo".to_string())));
|
|
}
|
|
|
|
#[test]
|
|
fn named_binder_runs_bit_identical_to_positional() {
|
|
// C1 equivalence: the named builder bootstraps to a run bit-identical to
|
|
// the positional vector, over the sample composite harness.
|
|
let (bp, comp_eq, comp_ex) = composite_sma_cross_harness();
|
|
let mut named = bp
|
|
.with("sma_cross.fast.length", 2)
|
|
.with("sma_cross.slow.length", 4)
|
|
.with("bias.scale", 0.5)
|
|
.bootstrap()
|
|
.expect("named binding resolves and bootstraps");
|
|
named.run(vec![Box::new(VecSource::new(synthetic_prices()))]);
|
|
let named_eq = comp_eq.try_iter().collect::<Vec<_>>();
|
|
let named_ex = comp_ex.try_iter().collect::<Vec<_>>();
|
|
|
|
let (bp2, pos_eq, pos_ex) = composite_sma_cross_harness();
|
|
let mut positional = bp2
|
|
.bootstrap_with_params(vec![Scalar::i64(2), Scalar::i64(4), Scalar::f64(0.5)])
|
|
.expect("positional bootstrap");
|
|
positional.run(vec![Box::new(VecSource::new(synthetic_prices()))]);
|
|
let pos_eq_v = pos_eq.try_iter().collect::<Vec<_>>();
|
|
let pos_ex_v = pos_ex.try_iter().collect::<Vec<_>>();
|
|
|
|
assert!(!named_eq.is_empty(), "named run drained empty");
|
|
assert_eq!(named_eq, pos_eq_v, "equity stream: named must equal positional");
|
|
assert_eq!(named_ex, pos_ex_v, "exposure stream: named must equal positional");
|
|
}
|
|
|
|
/// One f64 input port with `Firing::Any` (the common case for these fixtures).
|
|
fn f64_any() -> PortSpec {
|
|
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "in".into() }
|
|
}
|
|
/// A one-f64-field output record under name `v`.
|
|
fn out_v() -> Vec<FieldSpec> {
|
|
vec![FieldSpec { name: "v".into(), kind: ScalarKind::F64 }]
|
|
}
|
|
/// A bound root role of f64 kind, fanning into `targets`.
|
|
fn root_role(name: &str, targets: Vec<Target>) -> Role {
|
|
Role { name: name.into(), targets, source: Some(ScalarKind::F64) }
|
|
}
|
|
|
|
/// A 2-input f64 node, one f64 output. Test-local fixture (C9: examples for the
|
|
/// engine's own tests, no speculative `aura-std` surface).
|
|
struct Join2 {
|
|
out: [Cell; 1],
|
|
}
|
|
impl Node for Join2 {
|
|
fn lookbacks(&self) -> Vec<usize> {
|
|
vec![1, 1]
|
|
}
|
|
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
|
|
let a = ctx.f64_in(0);
|
|
let b = ctx.f64_in(1);
|
|
if a.is_empty() || b.is_empty() {
|
|
return None;
|
|
}
|
|
self.out[0] = Cell::from_f64(a[0] + b[0]);
|
|
Some(&self.out)
|
|
}
|
|
}
|
|
|
|
/// A 1-input f64 node, one f64 output. Test-local fixture.
|
|
struct Pass1 {
|
|
out: [Cell; 1],
|
|
}
|
|
impl Node for Pass1 {
|
|
fn lookbacks(&self) -> Vec<usize> {
|
|
vec![1]
|
|
}
|
|
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
|
|
let w = ctx.f64_in(0);
|
|
if w.is_empty() {
|
|
return None;
|
|
}
|
|
self.out[0] = Cell::from_f64(w[0]);
|
|
Some(&self.out)
|
|
}
|
|
}
|
|
|
|
/// A pure consumer with one f64 input and no output (sink role, C8).
|
|
struct SinkF64;
|
|
impl Node for SinkF64 {
|
|
fn lookbacks(&self) -> Vec<usize> {
|
|
vec![1]
|
|
}
|
|
fn eval(&mut self, _ctx: Ctx<'_>) -> Option<&[Cell]> {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// A pure consumer with one i64 input and no output. Used to provoke a role /
|
|
/// edge kind mismatch (its slot is i64 where an f64 is fanned in).
|
|
struct SinkI64;
|
|
impl Node for SinkI64 {
|
|
fn lookbacks(&self) -> Vec<usize> {
|
|
vec![1]
|
|
}
|
|
fn eval(&mut self, _ctx: Ctx<'_>) -> Option<&[Cell]> {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn pass1() -> BlueprintNode {
|
|
PrimitiveBuilder::new(
|
|
"Pass1",
|
|
NodeSchema { inputs: vec![f64_any()], output: out_v(), params: vec![] },
|
|
|_| Box::new(Pass1 { out: [Cell::from_f64(0.0)] }),
|
|
)
|
|
.into()
|
|
}
|
|
fn join2() -> BlueprintNode {
|
|
PrimitiveBuilder::new(
|
|
"Join2",
|
|
NodeSchema { inputs: vec![f64_any(), f64_any()], output: out_v(), params: vec![] },
|
|
|_| Box::new(Join2 { out: [Cell::from_f64(0.0)] }),
|
|
)
|
|
.into()
|
|
}
|
|
fn sink_f64() -> BlueprintNode {
|
|
PrimitiveBuilder::new(
|
|
"SinkF64",
|
|
NodeSchema { inputs: vec![f64_any()], output: vec![], params: vec![] },
|
|
|_| Box::new(SinkF64),
|
|
)
|
|
.into()
|
|
}
|
|
fn sink_i64() -> BlueprintNode {
|
|
PrimitiveBuilder::new(
|
|
"SinkI64",
|
|
NodeSchema {
|
|
inputs: vec![PortSpec { kind: ScalarKind::I64, firing: Firing::Any, name: "in".into() }],
|
|
output: vec![],
|
|
params: vec![],
|
|
},
|
|
|_| Box::new(SinkI64),
|
|
)
|
|
.into()
|
|
}
|
|
|
|
/// A composite: two Pass1 leaves feeding a Join2, role 0 fanning the source
|
|
/// into BOTH Pass1 slots, output = the Join2 field 0. The generic analogue of
|
|
/// the SMA-cross shape.
|
|
fn fan_composite() -> Composite {
|
|
Composite::new(
|
|
"fan",
|
|
vec![pass1(), pass1(), join2()],
|
|
vec![
|
|
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
|
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
|
|
],
|
|
vec![Role {
|
|
name: "price".into(),
|
|
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }], source: None, }],
|
|
vec![OutField { node: 2, field: 0, name: "out".into() }],
|
|
)
|
|
}
|
|
|
|
/// The SMA-cross signal as a composite under the boundary name `sma_cross`,
|
|
/// with its two SMA legs given `name` (or left default when `named` is false).
|
|
/// Nested under a root so the composite name qualifies the param-space path and
|
|
/// the fan-in check (which inspects nested composites) reaches the Sub fan-in.
|
|
fn sma_cross_under_root(named: bool) -> Composite {
|
|
use aura_std::{Sma, Sub};
|
|
let (a, b) = if named {
|
|
(Sma::builder().named("fast"), Sma::builder().named("slow"))
|
|
} else {
|
|
(Sma::builder(), Sma::builder())
|
|
};
|
|
let cross = Composite::new(
|
|
"sma_cross",
|
|
vec![a.into(), b.into(), Sub::builder().into()],
|
|
vec![
|
|
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
|
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
|
|
],
|
|
vec![Role {
|
|
name: "price".into(),
|
|
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
|
|
source: None,
|
|
}],
|
|
vec![OutField { node: 2, field: 0, name: "cross".into() }],
|
|
);
|
|
Composite::new(
|
|
"root",
|
|
vec![BlueprintNode::Composite(cross)],
|
|
vec![],
|
|
vec![Role {
|
|
name: "src".into(),
|
|
targets: vec![Target { node: 0, slot: 0 }],
|
|
source: Some(ScalarKind::F64),
|
|
}],
|
|
vec![],
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn named_siblings_path_qualify_with_node_segment() {
|
|
// two SMAs named fast/slow under composite "sma_cross" -> the node segment
|
|
// qualifies each leaf's param path under the composite name.
|
|
let bp = sma_cross_under_root(true);
|
|
let names: Vec<String> = bp.param_space().into_iter().map(|p| p.name).collect();
|
|
assert_eq!(names, ["sma_cross.fast.length", "sma_cross.slow.length"]);
|
|
}
|
|
|
|
#[test]
|
|
fn unnamed_single_primitive_uses_lowercased_type_label_segment() {
|
|
use aura_std::Sma;
|
|
let bp = Composite::new("root", vec![Sma::builder().into()], vec![], vec![], vec![]);
|
|
assert_eq!(bp.param_space()[0].name, "sma.length");
|
|
}
|
|
|
|
#[test]
|
|
fn unnamed_same_type_param_bearing_fan_in_is_rejected() {
|
|
// both SMAs default to "sma" -> collide -> fan-in indistinguishable
|
|
let bp = sma_cross_under_root(false);
|
|
assert_eq!(
|
|
bp.compile().err(),
|
|
Some(CompileError::DuplicateParamPath("sma_cross.sma.length".to_string()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn named_param_bearing_fan_in_bootstraps() {
|
|
// distinct node names -> fan-in distinguishable -> compiles (the two SMA
|
|
// length params are supplied so the only thing under test is the fan-in)
|
|
let bp = sma_cross_under_root(true);
|
|
assert!(bp.compile_with_params(&[Scalar::i64(2), Scalar::i64(4)]).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn single_composite_inlines_with_offset_fan_and_output() {
|
|
// composite as item 0; a source into its role 0; an edge out of it to a sink.
|
|
let bp = Composite::new(
|
|
"root",
|
|
vec![BlueprintNode::Composite(fan_composite()), sink_f64()],
|
|
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
|
|
vec![
|
|
Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) },
|
|
],
|
|
vec![], // output
|
|
);
|
|
let flat = bp.compile().expect("valid composite");
|
|
let (nodes, sources, edges) = (flat.nodes, flat.sources, flat.edges);
|
|
|
|
// 3 interior nodes (Pass1, Pass1, Join2) at flat 0..2, then SinkF64 at 3
|
|
assert_eq!(nodes.len(), 4);
|
|
// interior edges rewritten at offset 0, then the output edge resolves the
|
|
// composite's OutField (interior node 2, field 0) to the sink (flat node 3)
|
|
assert_eq!(
|
|
edges,
|
|
vec![
|
|
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
|
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
|
|
Edge { from: 2, to: 3, slot: 0, from_field: 0 },
|
|
]
|
|
);
|
|
// the source target into role 0 fanned into BOTH Pass1 slots
|
|
assert_eq!(sources.len(), 1);
|
|
assert_eq!(
|
|
sources[0].targets,
|
|
vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn composite_reexports_two_fields_to_distinct_consumers() {
|
|
// composite: two independent Pass1 leaves; role 0 -> leaf 0, role 1 -> leaf 1;
|
|
// output record re-exports leaf 0 as "a", leaf 1 as "b".
|
|
let c = Composite::new(
|
|
"two_out",
|
|
vec![pass1(), pass1()],
|
|
vec![],
|
|
vec![
|
|
Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None },
|
|
Role { name: "price2".into(), targets: vec![Target { node: 1, slot: 0 }], source: None },
|
|
],
|
|
vec![
|
|
OutField { node: 0, field: 0, name: "a".into() },
|
|
OutField { node: 1, field: 0, name: "b".into() },
|
|
],
|
|
);
|
|
// composite is item 0; two sinks (items 1, 2) read its two output fields by
|
|
// from_field; one source fans into both roles.
|
|
let bp = Composite::new(
|
|
"root",
|
|
vec![BlueprintNode::Composite(c), sink_f64(), sink_f64()],
|
|
vec![
|
|
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // field "a" -> sink 1
|
|
Edge { from: 0, to: 2, slot: 0, from_field: 1 }, // field "b" -> sink 2
|
|
],
|
|
vec![
|
|
Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }, Target { node: 0, slot: 1 }], source: Some(ScalarKind::F64) },
|
|
],
|
|
vec![], // output
|
|
);
|
|
let flat = bp.compile().expect("valid multi-output composite");
|
|
let (nodes, sources, edges) = (flat.nodes, flat.sources, flat.edges);
|
|
// flat layout: Pass1(0), Pass1(1), SinkF64(2), SinkF64(3)
|
|
assert_eq!(nodes.len(), 4);
|
|
// from_field 0 resolves to leaf 0, from_field 1 to leaf 1 — distinct producers
|
|
assert_eq!(
|
|
edges,
|
|
vec![
|
|
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
|
Edge { from: 1, to: 3, slot: 0, from_field: 0 },
|
|
]
|
|
);
|
|
// the source fanned into both interior leaves
|
|
assert_eq!(
|
|
sources[0].targets,
|
|
vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn nested_composite_inlines() {
|
|
// outer composite wraps the inner fan_composite as its only interior item,
|
|
// re-exposing the inner's role 0 (outer role 0 -> inner role 0) and the
|
|
// inner's output. A source into the outer role 0 must fan to BOTH inner
|
|
// Pass1 slots; the inner Join2 lands at flat index 2.
|
|
let inner = fan_composite();
|
|
let outer = Composite::new(
|
|
"outer",
|
|
vec![BlueprintNode::Composite(inner)],
|
|
vec![],
|
|
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
|
|
vec![OutField { node: 0, field: 0, name: "out".into() }],
|
|
);
|
|
let bp = Composite::new(
|
|
"root",
|
|
vec![BlueprintNode::Composite(outer), sink_f64()],
|
|
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
|
|
vec![
|
|
Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) },
|
|
],
|
|
vec![], // output
|
|
);
|
|
let flat = bp.compile().expect("valid nested composite");
|
|
let (nodes, sources, edges) = (flat.nodes, flat.sources, flat.edges);
|
|
|
|
assert_eq!(nodes.len(), 4); // Pass1, Pass1, Join2, SinkF64
|
|
assert_eq!(
|
|
sources[0].targets,
|
|
vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]
|
|
);
|
|
// inner interior edges + the output edge from the inner Join2 (flat 2) to sink
|
|
assert_eq!(
|
|
edges,
|
|
vec![
|
|
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
|
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
|
|
Edge { from: 2, to: 3, slot: 0, from_field: 0 },
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn outer_reexports_two_fields_of_inner_composite() {
|
|
// inner re-exports two leaves as "a","b"; outer re-exposes both inner roles
|
|
// and re-exports inner field 0 and field 1 (the latter exercises the nested arm).
|
|
let inner = Composite::new(
|
|
"inner_two",
|
|
vec![pass1(), pass1()],
|
|
vec![],
|
|
vec![
|
|
Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None },
|
|
Role { name: "price2".into(), targets: vec![Target { node: 1, slot: 0 }], source: None },
|
|
],
|
|
vec![
|
|
OutField { node: 0, field: 0, name: "a".into() },
|
|
OutField { node: 1, field: 0, name: "b".into() },
|
|
],
|
|
);
|
|
let outer = Composite::new(
|
|
"outer_two",
|
|
vec![BlueprintNode::Composite(inner)],
|
|
vec![],
|
|
vec![
|
|
Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }, // outer role 0 -> inner role 0
|
|
Role { name: "price2".into(), targets: vec![Target { node: 0, slot: 1 }], source: None }, // outer role 1 -> inner role 1
|
|
],
|
|
vec![
|
|
OutField { node: 0, field: 0, name: "x".into() }, // inner field 0
|
|
OutField { node: 0, field: 1, name: "y".into() }, // inner field 1 (nested arm)
|
|
],
|
|
);
|
|
let bp = Composite::new(
|
|
"root",
|
|
vec![BlueprintNode::Composite(outer), sink_f64(), sink_f64()],
|
|
vec![
|
|
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // outer field x -> sink 1
|
|
Edge { from: 0, to: 2, slot: 0, from_field: 1 }, // outer field y -> sink 2
|
|
],
|
|
vec![
|
|
Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }, Target { node: 0, slot: 1 }], source: Some(ScalarKind::F64) },
|
|
],
|
|
vec![], // output
|
|
);
|
|
let flat = bp.compile().expect("valid nested multi-output");
|
|
let (nodes, _sources, edges) = (flat.nodes, flat.sources, flat.edges);
|
|
assert_eq!(nodes.len(), 4); // Pass1, Pass1, SinkF64, SinkF64
|
|
assert_eq!(
|
|
edges,
|
|
vec![
|
|
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
|
Edge { from: 1, to: 3, slot: 0, from_field: 0 },
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bad_interior_index_rejected() {
|
|
// interior edge references interior node 9, which does not exist
|
|
let c = Composite::new(
|
|
"c",
|
|
vec![pass1()],
|
|
vec![Edge { from: 0, to: 9, slot: 0, from_field: 0 }],
|
|
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
|
|
vec![OutField { node: 0, field: 0, name: "out".into() }],
|
|
);
|
|
let bp = Composite::new(
|
|
"root",
|
|
vec![BlueprintNode::Composite(c)],
|
|
vec![],
|
|
vec![Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }],
|
|
vec![], // output
|
|
);
|
|
// the Ok arm holds Box<dyn Node> (not Debug), so assert via the Err arm.
|
|
assert_eq!(bp.compile().err(), Some(CompileError::BadInteriorIndex));
|
|
}
|
|
|
|
#[test]
|
|
fn indistinguishable_fan_in_rejected() {
|
|
// two default-named Sma (both "sma", each a `length` param) on role price
|
|
// into a Sub: node-name signatures collide and a param is present -> fault.
|
|
let c = Composite::new(
|
|
"ambig",
|
|
vec![Sma::builder().into(), Sma::builder().into(), Sub::builder().into()],
|
|
vec![
|
|
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
|
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
|
|
],
|
|
vec![Role {
|
|
name: "price".into(),
|
|
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }], source: None, }],
|
|
vec![OutField { node: 2, field: 0, name: "x".into() }],
|
|
);
|
|
let bp = Composite::new(
|
|
"root",
|
|
vec![BlueprintNode::Composite(c)],
|
|
vec![],
|
|
vec![
|
|
Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) },
|
|
],
|
|
vec![], // output
|
|
);
|
|
assert_eq!(
|
|
bp.compile().err(),
|
|
Some(CompileError::DuplicateParamPath("ambig.sma.length".to_string()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn interchangeable_fan_in_allowed() {
|
|
// fan_composite: two param-less Pass into a Join, equal signatures but no
|
|
// unaliased param -> interchangeable -> Ok.
|
|
let bp = Composite::new(
|
|
"root",
|
|
vec![BlueprintNode::Composite(fan_composite()), sink_f64()],
|
|
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
|
|
vec![
|
|
Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) },
|
|
],
|
|
vec![], // output
|
|
);
|
|
assert!(bp.compile().is_ok(), "param-less interchangeable fan-in must compile");
|
|
}
|
|
|
|
#[test]
|
|
fn role_kind_mismatch_rejected() {
|
|
// role 0 fans into a Pass1 f64 slot AND a SinkI64 i64 slot -> mismatch
|
|
let c = Composite::new(
|
|
"c",
|
|
vec![pass1(), sink_i64()],
|
|
vec![],
|
|
vec![Role {
|
|
name: "price".into(),
|
|
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }], source: None, }],
|
|
vec![OutField { node: 0, field: 0, name: "out".into() }],
|
|
);
|
|
let bp = Composite::new(
|
|
"root",
|
|
vec![BlueprintNode::Composite(c)],
|
|
vec![],
|
|
vec![Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }],
|
|
vec![], // output
|
|
);
|
|
// the Ok arm holds Box<dyn Node> (not Debug), so assert via the Err arm.
|
|
assert_eq!(bp.compile().err(), Some(CompileError::RoleKindMismatch { role: 0 }));
|
|
}
|
|
|
|
#[test]
|
|
fn output_port_out_of_range_rejected() {
|
|
// output names field 5 of a node whose output has one field
|
|
let c = Composite::new(
|
|
"c",
|
|
vec![pass1()],
|
|
vec![],
|
|
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
|
|
vec![OutField { node: 0, field: 5, name: "out".into() }],
|
|
);
|
|
let bp = Composite::new(
|
|
"root",
|
|
vec![BlueprintNode::Composite(c)],
|
|
vec![],
|
|
vec![Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }],
|
|
vec![], // output
|
|
);
|
|
// the Ok arm holds Box<dyn Node> (not Debug), so assert via the Err arm.
|
|
assert_eq!(bp.compile().err(), Some(CompileError::OutputPortOutOfRange));
|
|
}
|
|
|
|
#[test]
|
|
fn unconnected_interior_slot_rejected() {
|
|
// pass1 is fed by a role; sink_f64's one input is left unwired -> rejected.
|
|
let bp = Composite::new(
|
|
"root",
|
|
vec![pass1(), sink_f64()],
|
|
vec![],
|
|
vec![Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }],
|
|
vec![],
|
|
);
|
|
assert_eq!(
|
|
bp.compile().err(),
|
|
Some(CompileError::UnconnectedPort { node: 1, slot: 0 })
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn double_wired_slot_rejected_edge_and_role() {
|
|
// sink_f64's one input is targeted by BOTH an edge (from pass1) and a role.
|
|
let bp = Composite::new(
|
|
"root",
|
|
vec![pass1(), sink_f64()],
|
|
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
|
|
vec![
|
|
Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) },
|
|
Role { name: "extra".into(), targets: vec![Target { node: 1, slot: 0 }], source: Some(ScalarKind::F64) },
|
|
],
|
|
vec![],
|
|
);
|
|
assert_eq!(
|
|
bp.compile().err(),
|
|
Some(CompileError::DoubleWiredPort { node: 1, slot: 0 })
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn double_wired_slot_rejected_two_edges() {
|
|
// two producers' edges land on sink_f64's single input slot.
|
|
let bp = Composite::new(
|
|
"root",
|
|
vec![pass1(), pass1(), sink_f64()],
|
|
vec![
|
|
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
|
Edge { from: 1, to: 2, slot: 0, from_field: 0 },
|
|
],
|
|
vec![
|
|
Role { name: "a".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) },
|
|
Role { name: "b".into(), targets: vec![Target { node: 1, slot: 0 }], source: Some(ScalarKind::F64) },
|
|
],
|
|
vec![],
|
|
);
|
|
assert_eq!(
|
|
bp.compile().err(),
|
|
Some(CompileError::DoubleWiredPort { node: 2, slot: 0 })
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unconnected_slot_in_nested_composite_rejected() {
|
|
// inner composite c: pass1 (fed by c's role) + sink_f64 (interior slot
|
|
// unwired). The root covers c's one input, so the fault surfaces only via
|
|
// the recursion into c -> UnconnectedPort at c's interior index (1, 0).
|
|
let c = Composite::new(
|
|
"c",
|
|
vec![pass1(), sink_f64()],
|
|
vec![],
|
|
vec![Role { name: "in".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
|
|
vec![OutField { node: 0, field: 0, name: "out".into() }],
|
|
);
|
|
let bp = Composite::new(
|
|
"root",
|
|
vec![BlueprintNode::Composite(c)],
|
|
vec![],
|
|
vec![Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }],
|
|
vec![],
|
|
);
|
|
assert_eq!(
|
|
bp.compile().err(),
|
|
Some(CompileError::UnconnectedPort { node: 1, slot: 0 })
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn open_role_provider_is_not_flagged_unconnected() {
|
|
// a composite whose OPEN input role (source: None) feeds its interior slot,
|
|
// used as a nested node with that role covered by the enclosing root, must
|
|
// compile — the open role is a provider, not an unwired consumer.
|
|
let c = Composite::new(
|
|
"c",
|
|
vec![pass1()],
|
|
vec![],
|
|
vec![Role { name: "in".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
|
|
vec![OutField { node: 0, field: 0, name: "out".into() }],
|
|
);
|
|
let bp = Composite::new(
|
|
"root",
|
|
vec![BlueprintNode::Composite(c)],
|
|
vec![],
|
|
vec![Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }],
|
|
vec![],
|
|
);
|
|
assert!(bp.compile().is_ok(), "open role as provider must not be mis-flagged");
|
|
}
|
|
|
|
#[test]
|
|
fn consume_of_missing_output_field_is_rejected() {
|
|
// a single-field composite; a consumer reads from_field 1 (past the 1-field
|
|
// record) -> the rewrite_edge range-check rejects it.
|
|
let c = Composite::new(
|
|
"c",
|
|
vec![pass1()],
|
|
vec![],
|
|
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
|
|
vec![OutField { node: 0, field: 0, name: "a".into() }],
|
|
);
|
|
let bp = Composite::new(
|
|
"root",
|
|
vec![BlueprintNode::Composite(c), sink_f64()],
|
|
vec![Edge { from: 0, to: 1, slot: 0, from_field: 1 }],
|
|
vec![
|
|
Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) },
|
|
],
|
|
vec![], // output
|
|
);
|
|
assert_eq!(bp.compile().err(), Some(CompileError::BadInteriorIndex));
|
|
}
|
|
|
|
#[test]
|
|
fn bootstrap_error_is_wrapped() {
|
|
// a top-level kind mismatch: a Pass1 f64 output wired into a SinkI64 i64
|
|
// input. compile() lowers it faithfully; bootstrap's kind-check rejects it.
|
|
let bp = Composite::new(
|
|
"root",
|
|
vec![pass1(), sink_i64()],
|
|
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
|
|
vec![],
|
|
vec![], // output
|
|
);
|
|
match bp.bootstrap().unwrap_err() {
|
|
CompileError::Bootstrap(BootstrapError::KindMismatch { producer, consumer }) => {
|
|
assert_eq!(producer, ScalarKind::F64);
|
|
assert_eq!(consumer, ScalarKind::I64);
|
|
}
|
|
other => panic!("expected Bootstrap(KindMismatch), got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// A flat, hand-wired SMA-cross signal-quality harness, with two
|
|
/// recording sinks.
|
|
#[allow(clippy::type_complexity)]
|
|
fn hand_wired_sma_cross_harness() -> (
|
|
Harness,
|
|
mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
|
|
mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
|
|
) {
|
|
let (tx_eq, rx_eq) = mpsc::channel();
|
|
let (tx_ex, rx_ex) = mpsc::channel();
|
|
let f64_recorder_sig = || NodeSchema {
|
|
inputs: vec![f64_any()],
|
|
output: vec![],
|
|
params: vec![],
|
|
};
|
|
let h = Harness::bootstrap(FlatGraph {
|
|
nodes: vec![
|
|
Box::new(Sma::new(2)),
|
|
Box::new(Sma::new(4)),
|
|
Box::new(Sub::new()),
|
|
Box::new(Bias::new(0.5)),
|
|
Box::new(SimBroker::new(0.0001)),
|
|
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq)),
|
|
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_ex)),
|
|
],
|
|
signatures: vec![
|
|
Sma::builder().schema().clone(),
|
|
Sma::builder().schema().clone(),
|
|
Sub::builder().schema().clone(),
|
|
Bias::builder().schema().clone(),
|
|
SimBroker::builder(0.0001).schema().clone(),
|
|
f64_recorder_sig(),
|
|
f64_recorder_sig(),
|
|
],
|
|
sources: vec![SourceSpec {
|
|
kind: ScalarKind::F64,
|
|
targets: vec![
|
|
Target { node: 0, slot: 0 },
|
|
Target { node: 1, slot: 0 },
|
|
Target { node: 4, slot: 1 },
|
|
],
|
|
}],
|
|
edges: vec![
|
|
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
|
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
|
|
Edge { from: 2, to: 3, slot: 0, from_field: 0 },
|
|
Edge { from: 3, to: 4, slot: 0, from_field: 0 },
|
|
Edge { from: 4, to: 5, slot: 0, from_field: 0 },
|
|
Edge { from: 3, to: 6, slot: 0, from_field: 0 },
|
|
],
|
|
})
|
|
.expect("valid hand-wired DAG");
|
|
(h, rx_eq, rx_ex)
|
|
}
|
|
|
|
#[test]
|
|
fn composite_sma_cross_runs_bit_identical_to_hand_wired() {
|
|
let prices = synthetic_prices();
|
|
|
|
// (a) today's flat, hand-wired graph
|
|
let (mut flat, flat_eq, flat_ex) = hand_wired_sma_cross_harness();
|
|
flat.run(vec![Box::new(VecSource::new(prices.clone()))]);
|
|
|
|
// (b) the same graph authored as a composite blueprint, compiled
|
|
let (bp, comp_eq, comp_ex) = composite_sma_cross_harness();
|
|
let mut composed = bp
|
|
.bootstrap_with_params(vec![Scalar::i64(2), Scalar::i64(4), Scalar::f64(0.5)])
|
|
.expect("composite blueprint compiles");
|
|
composed.run(vec![Box::new(VecSource::new(prices))]);
|
|
|
|
let flat_eq_v = flat_eq.try_iter().collect::<Vec<_>>();
|
|
let flat_ex_v = flat_ex.try_iter().collect::<Vec<_>>();
|
|
let comp_eq_v = comp_eq.try_iter().collect::<Vec<_>>();
|
|
let comp_ex_v = comp_ex.try_iter().collect::<Vec<_>>();
|
|
|
|
// both recording sinks captured the same equity + exposure traces, bit-for-bit
|
|
assert_eq!(flat_eq_v, comp_eq_v, "equity traces differ");
|
|
assert_eq!(flat_ex_v, comp_ex_v, "exposure traces differ");
|
|
// and the trace is populated (non-degenerate), so the equality is meaningful
|
|
assert!(!comp_eq_v.is_empty(), "equity trace must be populated");
|
|
assert!(!comp_ex_v.is_empty(), "exposure trace must be populated");
|
|
}
|
|
|
|
/// E2E (cycle 0018): a composite's multi-field output record is selected
|
|
/// field-wise downstream all the way through `bootstrap + run` — two consumers
|
|
/// reading distinct `from_field`s off one multi-output composite record the two
|
|
/// distinct interior producers' streams, deterministically. The Task-2 unit
|
|
/// tests stop at `compile()` (edge resolution); this one runs the harness, so a
|
|
/// regression that resolved both taps to the same producer (or dropped a field)
|
|
/// would surface as identical recorded traces here, not just a bad edge table.
|
|
#[test]
|
|
fn multi_output_composite_taps_distinct_fields_through_a_run() {
|
|
let prices = synthetic_prices();
|
|
let (tx_a, rx_a) = mpsc::channel();
|
|
let (tx_b, rx_b) = mpsc::channel();
|
|
// composite: two SMAs of different lengths, each its own input role; the
|
|
// output record re-exports SMA-fast as "a" (field 0) and SMA-slow as "b"
|
|
// (field 1). One source fans into both roles; two recorders tap the two
|
|
// fields by from_field.
|
|
let c = Composite::new(
|
|
"two_sma",
|
|
vec![Sma::builder().named("fast").into(), Sma::builder().named("slow").into()],
|
|
vec![],
|
|
vec![
|
|
Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None },
|
|
Role { name: "price2".into(), targets: vec![Target { node: 1, slot: 0 }], source: None },
|
|
],
|
|
vec![
|
|
OutField { node: 0, field: 0, name: "a".into() },
|
|
OutField { node: 1, field: 0, name: "b".into() },
|
|
],
|
|
);
|
|
let bp = Composite::new(
|
|
"root",
|
|
vec![
|
|
BlueprintNode::Composite(c),
|
|
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_a).into(),
|
|
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_b).into(),
|
|
],
|
|
vec![
|
|
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // field "a" (SMA-2) -> recorder a
|
|
Edge { from: 0, to: 2, slot: 0, from_field: 1 }, // field "b" (SMA-4) -> recorder b
|
|
],
|
|
vec![
|
|
Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }, Target { node: 0, slot: 1 }], source: Some(ScalarKind::F64) },
|
|
],
|
|
vec![], // output
|
|
);
|
|
let mut h = bp
|
|
.bootstrap_with_params(vec![Scalar::i64(2), Scalar::i64(4)])
|
|
.expect("multi-output composite bootstraps");
|
|
h.run(vec![Box::new(VecSource::new(prices))]);
|
|
|
|
let a = rx_a.try_iter().collect::<Vec<_>>();
|
|
let b = rx_b.try_iter().collect::<Vec<_>>();
|
|
// both fields recorded something (the equality below is meaningful only if
|
|
// populated) and the two taps captured different streams — distinct fast vs
|
|
// slow SMA, so the two from_field selections resolve to distinct producers.
|
|
assert!(!a.is_empty() && !b.is_empty(), "both field taps must be populated");
|
|
assert_ne!(a, b, "the two from_field taps must record distinct interior streams");
|
|
}
|
|
|
|
#[test]
|
|
fn injecting_a_different_vector_changes_the_run() {
|
|
let prices = synthetic_prices();
|
|
let (bp, eq, _ex) = composite_sma_cross_harness();
|
|
let mut a = bp.bootstrap_with_params(vec![Scalar::i64(2), Scalar::i64(4), Scalar::f64(0.5)])
|
|
.expect("compiles");
|
|
a.run(vec![Box::new(VecSource::new(prices.clone()))]);
|
|
let a_eq = eq.try_iter().collect::<Vec<_>>();
|
|
|
|
let (bp2, eq2, _ex2) = composite_sma_cross_harness();
|
|
let mut b = bp2.bootstrap_with_params(vec![Scalar::i64(5), Scalar::i64(20), Scalar::f64(1.0)])
|
|
.expect("compiles");
|
|
b.run(vec![Box::new(VecSource::new(prices))]);
|
|
let b_eq = eq2.try_iter().collect::<Vec<_>>();
|
|
|
|
assert!(!a_eq.is_empty() && !b_eq.is_empty(), "both traces populated");
|
|
assert_ne!(a_eq, b_eq, "a different vector must yield a different run");
|
|
}
|
|
|
|
#[test]
|
|
fn wrong_kind_is_a_param_kind_mismatch() {
|
|
let (bp, _eq, _ex) = composite_sma_cross_harness();
|
|
// slot 0 is I64 (an SMA length); inject F64 there
|
|
let err = bp.bootstrap_with_params(vec![Scalar::f64(2.0), Scalar::i64(4), Scalar::f64(0.5)])
|
|
.unwrap_err();
|
|
assert!(matches!(err, CompileError::ParamKindMismatch { slot: 0, .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn wrong_arity_is_a_param_arity_error() {
|
|
let (short, _e1, _x1) = composite_sma_cross_harness();
|
|
assert!(matches!(
|
|
short.bootstrap_with_params(vec![Scalar::i64(2)]).unwrap_err(),
|
|
CompileError::ParamArity { expected: 3, got: 1 }
|
|
));
|
|
let (long, _e2, _x2) = composite_sma_cross_harness();
|
|
assert!(matches!(
|
|
long.bootstrap_with_params(
|
|
vec![Scalar::i64(2), Scalar::i64(4), Scalar::f64(0.5), Scalar::f64(0.0)]
|
|
).unwrap_err(),
|
|
CompileError::ParamArity { expected: 3, got: 4 }
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn same_vector_bootstraps_identically() {
|
|
let prices = synthetic_prices();
|
|
let (bp, eq, _ex) = composite_sma_cross_harness();
|
|
let mut a = bp.bootstrap_with_params(vec![Scalar::i64(3), Scalar::i64(9), Scalar::f64(0.7)])
|
|
.expect("compiles");
|
|
a.run(vec![Box::new(VecSource::new(prices.clone()))]);
|
|
let (bp2, eq2, _ex2) = composite_sma_cross_harness();
|
|
let mut b = bp2.bootstrap_with_params(vec![Scalar::i64(3), Scalar::i64(9), Scalar::f64(0.7)])
|
|
.expect("compiles");
|
|
b.run(vec![Box::new(VecSource::new(prices))]);
|
|
assert_eq!(eq.try_iter().collect::<Vec<_>>(), eq2.try_iter().collect::<Vec<_>>());
|
|
}
|
|
|
|
/// E2E (cycle 0015): the C23/#31 cross-cutting invariant — `param_space()` is a
|
|
/// parallel projection of the *same* traversal `compile` inlines, so a param's
|
|
/// slot in the aggregated space lines up, in order and kind, with the declared
|
|
/// params of the compiled flat nodes (the premise #31's slot-by-slot binding
|
|
/// rests on). Driven on the realistic SMA-cross harness, not a synthetic graph,
|
|
/// and on the blueprint *as compiled* — so a future inliner reorder that
|
|
/// silently desynced the two projections would fail here, not just the isolated
|
|
/// `param_space` order tests.
|
|
#[test]
|
|
fn param_space_mirrors_compiled_flat_node_param_order() {
|
|
let (bp, _rx_eq, _rx_ex) = composite_sma_cross_harness();
|
|
|
|
// the aggregated, path-qualified projection
|
|
let space = bp.param_space();
|
|
|
|
// the same blueprint, actually compiled to its flat node array; each flat
|
|
// node's own declared params, concatenated in flat-node order
|
|
let flat = bp.compile_with_params(&[Scalar::i64(2), Scalar::i64(4), Scalar::f64(0.5)]).expect("harness compiles");
|
|
let from_flat: Vec<ParamSpec> =
|
|
flat.signatures.iter().flat_map(|s| s.params.clone()).collect();
|
|
|
|
// same count, same per-slot kind, same order — the projection mirrors the
|
|
// compilation (names differ: param_space path-qualifies, the raw node does
|
|
// not, so compare on the load-bearing axis, kind-by-slot)
|
|
assert_eq!(space.len(), from_flat.len(), "param count must match the flat graph");
|
|
assert_eq!(
|
|
space.iter().map(|p| p.kind).collect::<Vec<_>>(),
|
|
from_flat.iter().map(|p| p.kind).collect::<Vec<_>>(),
|
|
"per-slot param kinds must line up with the compiled flat-node order",
|
|
);
|
|
// the realistic harness's concrete space: two SMA lengths (I64) + Bias
|
|
// scale (F64); Sub/SimBroker/Recorder declare none
|
|
assert_eq!(
|
|
space.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
|
|
["sma_cross.fast.length", "sma_cross.slow.length", "bias.scale"],
|
|
);
|
|
assert_eq!(
|
|
space.iter().map(|p| p.kind).collect::<Vec<_>>(),
|
|
[ScalarKind::I64, ScalarKind::I64, ScalarKind::F64],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn param_space_is_flat_path_qualified_and_slot_disambiguated() {
|
|
use aura_std::{LinComb, Sma, Sub};
|
|
// inner composite "fast_slow": two named SMAs (fast/slow) + a Sub
|
|
let fast_slow = Composite::new(
|
|
"fast_slow",
|
|
vec![
|
|
Sma::builder().named("fast").into(),
|
|
Sma::builder().named("slow").into(),
|
|
Sub::builder().into(),
|
|
],
|
|
vec![
|
|
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
|
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
|
|
],
|
|
vec![Role {
|
|
name: "price".into(),
|
|
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }], source: None, }],
|
|
vec![OutField { node: 2, field: 0, name: "out".into() }],
|
|
);
|
|
// outer composite "strategy": the inner composite + a LinComb([1,-1])
|
|
let strategy = Composite::new(
|
|
"strategy",
|
|
vec![BlueprintNode::Composite(fast_slow), LinComb::builder(2).into()],
|
|
vec![],
|
|
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
|
|
vec![OutField { node: 0, field: 0, name: "out".into() }],
|
|
);
|
|
let bp = Composite::new(
|
|
"root",
|
|
vec![BlueprintNode::Composite(strategy)],
|
|
vec![],
|
|
vec![],
|
|
vec![], // output
|
|
);
|
|
|
|
let space = bp.param_space();
|
|
let names: Vec<&str> = space.iter().map(|p| p.name.as_str()).collect();
|
|
assert_eq!(
|
|
names,
|
|
[
|
|
"strategy.fast_slow.fast.length", // slot 0 — Sma "fast"
|
|
"strategy.fast_slow.slow.length", // slot 1 — Sma "slow"
|
|
"strategy.lincomb.weights[0]", // slot 2 — LinComb weight 0
|
|
"strategy.lincomb.weights[1]", // slot 3 — LinComb weight 1
|
|
]
|
|
);
|
|
assert_eq!(space[0].kind, ScalarKind::I64);
|
|
assert_eq!(space[2].kind, ScalarKind::F64);
|
|
}
|
|
|
|
#[test]
|
|
fn param_space_reflects_only_open_knobs() {
|
|
use aura_std::{Bias, Sma};
|
|
// "sma2_entry": Sma "bias" with length bound to 2 (a structural constant)
|
|
// plus Bias "exp" whose `scale` stays open. The bound knob must be
|
|
// absent from param_space; only the open one remains.
|
|
let strat = Composite::new(
|
|
"sma2_entry",
|
|
vec![
|
|
Sma::builder().named("bias").bind("length", Scalar::i64(2)).into(),
|
|
Bias::builder().named("exp").into(),
|
|
],
|
|
vec![], // edges — irrelevant to param_space()
|
|
vec![], // input_roles
|
|
vec![], // output
|
|
);
|
|
let space = strat.param_space();
|
|
let names: Vec<&str> = space.iter().map(|p| p.name.as_str()).collect();
|
|
// collect_params runs with an empty prefix, so a top-level leaf is qualified
|
|
// by its OWN node segment, not the root composite's name → "exp.scale".
|
|
// `bias.length` is GONE (bound), not present-but-fixed.
|
|
assert_eq!(names, ["exp.scale"]);
|
|
}
|
|
|
|
/// E2E (issue #34): the C23/#31 mirror invariant *under composite nesting*.
|
|
/// `param_space()` (via `collect_params`) duplicates `lower_items`' depth-
|
|
/// first traversal rather than sharing it, so the two orders must stay in
|
|
/// lockstep. The single-level mirror test
|
|
/// (`param_space_mirrors_compiled_flat_node_param_order`) never compiles a
|
|
/// composite whose interior holds *another* composite; the nested
|
|
/// `param_space` order test never compiles. This closes that gap: it
|
|
/// compiles a `strategy → { fast_slow → [Sma, Sma, Sub], LinComb }` nest and
|
|
/// asserts the aggregated space lines up, kind-by-slot, with the compiled
|
|
/// flat-node param order — so a future inliner reorder that desynced the two
|
|
/// projections *only under nesting* would fail here, not slip through.
|
|
#[test]
|
|
fn param_space_mirrors_compiled_flat_node_param_order_under_nesting() {
|
|
use aura_std::{LinComb, Sma, Sub};
|
|
// inner composite "fast_slow": two SMAs + a Sub (same nest as the
|
|
// isolated path-qualification test above)
|
|
let fast_slow = Composite::new(
|
|
"fast_slow",
|
|
vec![
|
|
Sma::builder().named("fast").into(),
|
|
Sma::builder().named("slow").into(),
|
|
Sub::builder().into(),
|
|
],
|
|
vec![
|
|
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
|
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
|
|
],
|
|
vec![Role {
|
|
name: "price".into(),
|
|
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }], source: None, }],
|
|
vec![OutField { node: 2, field: 0, name: "out".into() }],
|
|
);
|
|
// outer composite "strategy": the inner composite + a LinComb([1,-1])
|
|
let strategy = Composite::new(
|
|
"strategy",
|
|
vec![BlueprintNode::Composite(fast_slow), LinComb::builder(2).into()],
|
|
// fan fast_slow's output into both LinComb terms so every interior slot
|
|
// is wired (the totality check, cycle 0040); param order is unaffected.
|
|
vec![
|
|
Edge { from: 0, to: 1, slot: 0, from_field: 0 },
|
|
Edge { from: 0, to: 1, slot: 1, from_field: 0 },
|
|
],
|
|
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
|
|
vec![OutField { node: 0, field: 0, name: "out".into() }],
|
|
);
|
|
let bp = Composite::new(
|
|
"root",
|
|
vec![BlueprintNode::Composite(strategy)],
|
|
vec![],
|
|
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }],
|
|
vec![], // output
|
|
);
|
|
|
|
// the aggregated, path-qualified projection (borrows; take it first since
|
|
// compile() consumes self — same ordering as the single-level mirror test)
|
|
let space = bp.param_space();
|
|
|
|
// the same blueprint, compiled to its flat node array; each flat node's
|
|
// own declared params, concatenated in flat-node order
|
|
let flat = bp.compile_with_params(&[Scalar::i64(2), Scalar::i64(4), Scalar::f64(1.0), Scalar::f64(-1.0)]).expect("nested composite compiles");
|
|
let from_flat: Vec<ParamSpec> =
|
|
flat.signatures.iter().flat_map(|s| s.params.clone()).collect();
|
|
|
|
// same count, same per-slot kind, same order — the nested projection
|
|
// mirrors the compilation (names differ: param_space path-qualifies, the
|
|
// raw node does not, so compare on the load-bearing axis, kind-by-slot)
|
|
assert_eq!(space.len(), from_flat.len(), "param count must match the flat graph");
|
|
assert_eq!(
|
|
space.iter().map(|p| p.kind).collect::<Vec<_>>(),
|
|
from_flat.iter().map(|p| p.kind).collect::<Vec<_>>(),
|
|
"per-slot param kinds must line up with the compiled flat-node order, under nesting",
|
|
);
|
|
// pin the concrete shape: two Sma lengths (I64), Sub none, two LinComb
|
|
// weights (F64)
|
|
assert_eq!(
|
|
space.iter().map(|p| p.kind).collect::<Vec<_>>(),
|
|
[ScalarKind::I64, ScalarKind::I64, ScalarKind::F64, ScalarKind::F64],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn top_level_leaf_params_carry_the_node_segment() {
|
|
use aura_std::Sma;
|
|
let bp = Composite::new("root", vec![Sma::builder().into()], vec![], vec![], vec![]);
|
|
let space = bp.param_space();
|
|
assert_eq!(space.len(), 1);
|
|
// the root node now carries its own node-name segment (default "sma")
|
|
assert_eq!(space[0].name, "sma.length");
|
|
}
|
|
|
|
#[test]
|
|
fn param_space_is_deterministic() {
|
|
use aura_std::{LinComb, Sma};
|
|
let bp = Composite::new(
|
|
"root",
|
|
vec![Sma::builder().into(), LinComb::builder(2).into()],
|
|
vec![],
|
|
vec![],
|
|
vec![], // output
|
|
);
|
|
assert_eq!(bp.param_space(), bp.param_space()); // pure structural function (C1)
|
|
}
|
|
|
|
#[test]
|
|
fn param_space_empty_for_paramless_and_empty_blueprints() {
|
|
use aura_std::{Add, Sub};
|
|
let only_paramless =
|
|
Composite::new(
|
|
"root",
|
|
vec![Sub::builder().into(), Add::builder().into()],
|
|
vec![],
|
|
vec![],
|
|
vec![], // output
|
|
);
|
|
assert!(only_paramless.param_space().is_empty());
|
|
let empty = Composite::new(
|
|
"root",
|
|
vec![],
|
|
vec![],
|
|
vec![],
|
|
vec![], // output
|
|
);
|
|
assert!(empty.param_space().is_empty());
|
|
}
|
|
|
|
/// A macd-like composite: one f64 input role `price`, three f64 outputs
|
|
/// (macd/signal/histogram), three params (fast/slow/signal lengths). A typed
|
|
/// multi-output boundary, used to exercise `derive_signature` on a
|
|
/// composite.
|
|
fn macd_fixture() -> Composite {
|
|
Composite::new(
|
|
"macd",
|
|
vec![
|
|
Ema::builder().named("fast").into(), // 0 fast EMA
|
|
Ema::builder().named("slow").into(), // 1 slow EMA
|
|
Sub::builder().into(), // 2 macd = fast - slow
|
|
Ema::builder().named("signal").into(), // 3 signal EMA of macd
|
|
Sub::builder().into(), // 4 histogram = macd - signal
|
|
],
|
|
vec![
|
|
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
|
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
|
|
Edge { from: 2, to: 3, slot: 0, from_field: 0 },
|
|
Edge { from: 2, to: 4, slot: 0, from_field: 0 },
|
|
Edge { from: 3, to: 4, slot: 1, from_field: 0 },
|
|
],
|
|
vec![root_role("price", vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }])],
|
|
vec![
|
|
OutField { node: 2, field: 0, name: "macd".into() },
|
|
OutField { node: 3, field: 0, name: "signal".into() },
|
|
OutField { node: 4, field: 0, name: "histogram".into() },
|
|
],
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn primitive_signature_equals_builder_schema() {
|
|
// a primitive's pre-build signature IS its builder's declared schema
|
|
let b = Sma::builder();
|
|
let node = BlueprintNode::Primitive(Sma::builder());
|
|
assert_eq!(node.signature(), b.schema().clone());
|
|
}
|
|
|
|
#[test]
|
|
fn composite_signature_is_derived_from_interior() {
|
|
// macd composite: 1 f64 input role; output macd/signal/histogram (all f64)
|
|
let node = BlueprintNode::Composite(macd_fixture());
|
|
let sig = node.signature();
|
|
assert_eq!(sig.inputs.len(), 1);
|
|
assert_eq!(sig.inputs[0].kind, ScalarKind::F64);
|
|
assert_eq!(sig.output.iter().map(|f| f.kind).collect::<Vec<_>>(), vec![ScalarKind::F64; 3]);
|
|
assert_eq!(sig.params.len(), 3); // fast, slow, signal
|
|
}
|
|
|
|
#[test]
|
|
fn compile_rejects_kind_mismatch_without_building() {
|
|
// a builder whose build closure PANICS if called — proves validation is pre-build
|
|
let exploding = PrimitiveBuilder::new(
|
|
"Boom",
|
|
NodeSchema {
|
|
inputs: vec![PortSpec { kind: ScalarKind::I64, firing: Firing::Any, name: "in".into() }],
|
|
output: vec![FieldSpec { name: "v".into(), kind: ScalarKind::I64 }],
|
|
params: vec![],
|
|
},
|
|
|_| panic!("build must not run when validation fails pre-build"),
|
|
);
|
|
// an f64 producer wired into Boom's i64 slot
|
|
let root = Composite::new(
|
|
"root",
|
|
vec![Sma::builder().into(), exploding.into()],
|
|
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }], // f64 -> i64 slot
|
|
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }],
|
|
vec![],
|
|
);
|
|
let err = root.compile_with_params(&[Scalar::i64(3)]);
|
|
// kind fault caught pre-build (no panic) — same variant bootstrap would give
|
|
assert!(matches!(
|
|
err,
|
|
Err(CompileError::Bootstrap(BootstrapError::KindMismatch { .. }))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn unbound_root_role_is_rejected() {
|
|
let root = Composite::new(
|
|
"root",
|
|
vec![Sma::builder().into()],
|
|
vec![],
|
|
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
|
|
vec![],
|
|
);
|
|
// the Ok arm holds a FlatGraph (not Debug), so assert via the Err arm.
|
|
assert_eq!(
|
|
root.compile_with_params(&[Scalar::i64(3)]).err(),
|
|
Some(CompileError::UnboundRootRole { role: 0 })
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn lookbacks_arity_matches_signature_inputs() {
|
|
use aura_std::{Add, Sma};
|
|
// every std node: one lookback per declared input. Sma keeps its window in
|
|
// node state now (Kahan running sum), so its lookback is 1 (a depth-1 input),
|
|
// not `length` — the arity (one lookback per input) is what this test guards.
|
|
assert_eq!(Sma::new(3).lookbacks(), vec![1]);
|
|
assert_eq!(Sma::new(3).lookbacks().len(), Sma::builder().schema().inputs.len());
|
|
assert_eq!(Add::new().lookbacks().len(), Add::builder().schema().inputs.len());
|
|
}
|
|
|
|
#[test]
|
|
fn non_fan_in_duplicate_path_is_rejected() {
|
|
use aura_std::Sma;
|
|
// two unnamed SMAs ("sma" each), each feeding its OWN sink — no shared
|
|
// fan-in node, so the old fan-in check never fired; but param_space() has
|
|
// the path "dup.sma.length" twice.
|
|
let dup = Composite::new(
|
|
"dup",
|
|
vec![
|
|
Sma::builder().into(), // node 0 -> dup.sma.length
|
|
Sma::builder().into(), // node 1 -> dup.sma.length (duplicate)
|
|
sink_f64(), // node 2
|
|
sink_f64(), // node 3
|
|
],
|
|
vec![
|
|
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
|
Edge { from: 1, to: 3, slot: 0, from_field: 0 },
|
|
],
|
|
vec![Role {
|
|
name: "price".into(),
|
|
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
|
|
source: None,
|
|
}],
|
|
vec![],
|
|
);
|
|
let bp = Composite::new(
|
|
"root",
|
|
vec![BlueprintNode::Composite(dup)],
|
|
vec![],
|
|
vec![Role {
|
|
name: "src".into(),
|
|
targets: vec![Target { node: 0, slot: 0 }],
|
|
source: Some(ScalarKind::F64),
|
|
}],
|
|
vec![],
|
|
);
|
|
// two params declared (one per SMA); supply both so the only failure under
|
|
// test is the duplicate path (green today, DuplicateParamPath after).
|
|
assert_eq!(
|
|
bp.compile_with_params(&[Scalar::i64(2), Scalar::i64(3)]).err(),
|
|
Some(CompileError::DuplicateParamPath("dup.sma.length".to_string()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn asymmetric_node_name_collision_compiles() {
|
|
use aura_std::{Sma, Sub};
|
|
// inner composite `asym`: a param-bearing Sma (default "sma") + a paramless
|
|
// Pass1 forced to "sma", both on role price fanning into a Sub. Equal
|
|
// node-name signatures + one param -> rejected today (IndistinguishableFanIn);
|
|
// param_space() is the single injective entry ["asym.sma.length"] (the
|
|
// paramless leg contributes no path) -> admitted after the change. Pass1 is
|
|
// built inline (the pass1() helper returns an already-.into()'d node, so it
|
|
// cannot take .named).
|
|
let paramless_sma = PrimitiveBuilder::new(
|
|
"Pass1",
|
|
NodeSchema { inputs: vec![f64_any()], output: out_v(), params: vec![] },
|
|
|_| Box::new(Pass1 { out: [Cell::from_f64(0.0)] }),
|
|
)
|
|
.named("sma");
|
|
let asym = Composite::new(
|
|
"asym",
|
|
vec![Sma::builder().into(), paramless_sma.into(), Sub::builder().into()],
|
|
vec![
|
|
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
|
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
|
|
],
|
|
vec![Role {
|
|
name: "price".into(),
|
|
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
|
|
source: None,
|
|
}],
|
|
vec![OutField { node: 2, field: 0, name: "out".into() }],
|
|
);
|
|
let bp = Composite::new(
|
|
"root",
|
|
vec![BlueprintNode::Composite(asym)],
|
|
vec![],
|
|
vec![Role {
|
|
name: "src".into(),
|
|
targets: vec![Target { node: 0, slot: 0 }],
|
|
source: Some(ScalarKind::F64),
|
|
}],
|
|
vec![],
|
|
);
|
|
assert_eq!(
|
|
bp.param_space().into_iter().map(|p| p.name).collect::<Vec<_>>(),
|
|
["asym.sma.length"]
|
|
);
|
|
assert!(bp.compile_with_params(&[Scalar::i64(2)]).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn by_name_bootstrap_of_unnamed_cross_reports_duplicate_path() {
|
|
// the canonical by-name flow on an unnamed cross: the binder runs the
|
|
// injectivity check before name resolution, so the author sees the
|
|
// structural DuplicateParamPath (carrying the .named(...) cure) instead of
|
|
// AmbiguousKnob.
|
|
let bp = sma_cross_under_root(false);
|
|
assert_eq!(
|
|
bp.with("sma_cross.sma.length", 2).bootstrap().err(),
|
|
Some(BindError::Compile(CompileError::DuplicateParamPath(
|
|
"sma_cross.sma.length".to_string()
|
|
)))
|
|
);
|
|
}
|
|
|
|
/// E2E (cycle 0032): the whole collision → cure → run arc on the canonical
|
|
/// by-name flow. The same un-named SMA cross that `bootstrap` rejects as
|
|
/// `DuplicateParamPath` becomes runnable the moment its two legs are `.named()`
|
|
/// apart — the cure the error itself prescribes. Naming makes `param_space()`
|
|
/// injective, the by-name `.with(...).bootstrap()` terminal resolves the two
|
|
/// now-distinct paths, and the harness runs to a populated, non-degenerate
|
|
/// exposure trace. A regression that made injectivity gate the run (rather than
|
|
/// only the duplicate) — or that broke the by-name resolution of the cured
|
|
/// space — would surface here as a bootstrap error or an empty trace, not just a
|
|
/// bad `compile()` Err.
|
|
#[test]
|
|
fn named_cross_resolves_by_name_and_runs_to_a_trace() {
|
|
// root { sma_cross[ fast/slow SMA -> Sub ] -> Bias -> SimBroker -> rec },
|
|
// plus an exposure tap, mirroring composite_sma_cross_harness but driven
|
|
// through the by-name binder so the injective cured space is exercised
|
|
// end-to-end (resolve + bootstrap + run), not just at compile().
|
|
let prices = synthetic_prices();
|
|
let (tx_ex, rx_ex) = mpsc::channel();
|
|
let cross = Composite::new(
|
|
"sma_cross",
|
|
vec![
|
|
Sma::builder().named("fast").into(),
|
|
Sma::builder().named("slow").into(),
|
|
Sub::builder().into(),
|
|
],
|
|
vec![
|
|
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
|
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
|
|
],
|
|
vec![Role {
|
|
name: "price".into(),
|
|
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
|
|
source: None,
|
|
}],
|
|
vec![OutField { node: 2, field: 0, name: "out".into() }],
|
|
);
|
|
let bp = Composite::new(
|
|
"root",
|
|
vec![
|
|
BlueprintNode::Composite(cross),
|
|
Bias::builder().named("bias").into(),
|
|
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex).into(),
|
|
],
|
|
vec![
|
|
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // cross out -> Bias
|
|
Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // exposure -> recorder
|
|
],
|
|
vec![Role {
|
|
name: "src".into(),
|
|
targets: vec![Target { node: 0, slot: 0 }],
|
|
source: Some(ScalarKind::F64),
|
|
}],
|
|
vec![],
|
|
);
|
|
// the un-named twin of this blueprint is rejected (see
|
|
// by_name_bootstrap_of_unnamed_cross_reports_duplicate_path); the named one
|
|
// resolves its two distinct paths by name and bootstraps.
|
|
let mut h = bp
|
|
.with("sma_cross.fast.length", 2)
|
|
.with("sma_cross.slow.length", 4)
|
|
.with("bias.scale", 0.5)
|
|
.bootstrap()
|
|
.expect("named (injective) cross resolves by name and bootstraps");
|
|
h.run(vec![Box::new(VecSource::new(prices))]);
|
|
let ex = rx_ex.try_iter().collect::<Vec<_>>();
|
|
assert!(!ex.is_empty(), "the cured, by-name-bound cross must run to a populated exposure trace");
|
|
}
|
|
|
|
#[test]
|
|
fn composite_lifts_into_blueprint_node_via_from() {
|
|
let c = Composite::new(
|
|
"leaf",
|
|
vec![Sub::builder().into()],
|
|
vec![],
|
|
vec![],
|
|
vec![OutField { node: 0, field: 0, name: "o".into() }],
|
|
);
|
|
let bn: BlueprintNode = c.into();
|
|
assert!(matches!(bn, BlueprintNode::Composite(_)));
|
|
}
|
|
}
|