diff --git a/crates/aura-core/src/lib.rs b/crates/aura-core/src/lib.rs index 77b83f5..8926e44 100644 --- a/crates/aura-core/src/lib.rs +++ b/crates/aura-core/src/lib.rs @@ -43,7 +43,7 @@ pub use column::{Column, Window}; pub use ctx::Ctx; pub use error::KindMismatch; pub use node::{ - zip_params, BoundParam, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, + zip_params, BindOpError, BoundParam, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, }; pub use scalar::{Scalar, ScalarKind, Timestamp}; diff --git a/crates/aura-core/src/node.rs b/crates/aura-core/src/node.rs index c51608e..4888098 100644 --- a/crates/aura-core/src/node.rs +++ b/crates/aura-core/src/node.rs @@ -235,6 +235,65 @@ impl PrimitiveBuilder { }); self } + + /// The fallible twin of [`bind`](Self::bind): same exactly-one-match + kind + /// rule, returning [`BindOpError`] instead of panicking. Used by the op-script + /// construction surface, where a bad param is rejected at the op, not a panic. + pub fn try_bind(mut self, slot: &str, value: Scalar) -> Result { + let matches: Vec = self + .schema + .params + .iter() + .enumerate() + .filter(|(_, p)| p.name == slot) + .map(|(i, _)| i) + .collect(); + let pos = match matches.as_slice() { + [pos] => *pos, + [] => return Err(BindOpError::UnknownParam(slot.to_string())), + _ => return Err(BindOpError::AmbiguousParam(slot.to_string())), + }; + if value.kind() != self.schema.params[pos].kind { + return Err(BindOpError::KindMismatch { + param: slot.to_string(), + expected: self.schema.params[pos].kind, + got: value.kind(), + }); + } + let name = self.schema.params[pos].name.clone(); + let kind = self.schema.params[pos].kind; + let mut orig = pos; + let mut prior: Vec = self.bound.iter().map(|b| b.pos).collect(); + prior.sort_unstable(); + for p in prior { + if p <= orig { + orig += 1; + } + } + self.bound.push(BoundParam { pos: orig, name, kind, value }); + self.schema.params.remove(pos); + let inner = self.build; + self.build = Box::new(move |open: &[Cell]| { + let mut full = open.to_vec(); + full.insert(pos, value.cell()); + inner(&full) + }); + Ok(self) + } +} + +/// A fallible-bind fault — the `Result` twin of `bind`'s panics, for the +/// data-level construction surface (`GraphSession`, #157). `bind` keeps its +/// panic contract (and its pinned messages); `try_bind` reports the same three +/// conditions as values. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BindOpError { + /// No still-open param has this name. + UnknownParam(String), + /// More than one still-open param has this name. + AmbiguousParam(String), + /// The value's kind does not equal the param's declared kind. + KindMismatch { param: String, expected: ScalarKind, got: ScalarKind }, } /// A node's declared interface: its inputs (in order) and its output record — an @@ -550,6 +609,41 @@ mod tests { ); let _ = b.bind("length", Scalar::f64(2.0)); // F64 value for an I64 slot } + + #[test] + fn try_bind_ok_and_typed_errors() { + use super::BindOpError; + // A probe with one i64 param `length` (a real std node like `Sma` would + // pull in aura-std, but aura-core cannot dev-depend on it without a + // dev-cycle that compiles aura-core twice — incompatible `Scalar` types). + let probe = || { + PrimitiveBuilder::new( + "Probe", + NodeSchema { + inputs: vec![], + output: vec![], + params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }], + }, + |_| Box::new(Bare), + ) + }; + // ok: exact name, matching kind + assert!(probe().try_bind("length", Scalar::i64(3)).is_ok()); + // unknown param name + assert_eq!( + probe().try_bind("nope", Scalar::i64(3)).err(), + Some(BindOpError::UnknownParam("nope".into())) + ); + // kind mismatch (f64 into an i64 param) + assert_eq!( + probe().try_bind("length", Scalar::f64(3.0)).err(), + Some(BindOpError::KindMismatch { + param: "length".into(), + expected: ScalarKind::I64, + got: ScalarKind::F64, + }) + ); + } } #[cfg(test)] diff --git a/crates/aura-engine/src/blueprint.rs b/crates/aura-engine/src/blueprint.rs index afca2de..703ab78 100644 --- a/crates/aura-engine/src/blueprint.rs +++ b/crates/aura-engine/src/blueprint.rs @@ -563,7 +563,7 @@ fn resolve_ranges(space: &[ParamSpec], ranges: &[(String, ParamRange)]) -> Resul /// `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. -fn check_param_namespace_injective(space: &[ParamSpec]) -> Result<(), CompileError> { +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()) { @@ -620,12 +620,34 @@ pub enum CompileError { DoubleWiredPort { node: usize, slot: 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. -fn validate_wiring( +pub(crate) fn validate_wiring( nodes: &[BlueprintNode], edges: &[Edge], roles: &[Role], @@ -638,14 +660,7 @@ fn validate_wiring( 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(); - let f = from.output.get(e.from_field).ok_or(CompileError::BadInteriorIndex)?; - let s = to.inputs.get(e.slot).ok_or(CompileError::BadInteriorIndex)?; - if f.kind != s.kind { - return Err(CompileError::Bootstrap(BootstrapError::KindMismatch { - producer: f.kind, - consumer: s.kind, - })); - } + 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()). @@ -926,6 +941,34 @@ mod tests { use aura_std::{Bias, Ema, Recorder, SimBroker, Sma, Sub}; use std::sync::mpsc; + #[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 diff --git a/crates/aura-engine/src/builder.rs b/crates/aura-engine/src/builder.rs index 0e1ea6d..1a76b4a 100644 --- a/crates/aura-engine/src/builder.rs +++ b/crates/aura-engine/src/builder.rs @@ -162,17 +162,14 @@ impl GraphBuilder { .schemas .get(p.node) .ok_or(BuildError::BadHandle { node: p.node })?; - let m: Vec = schema - .inputs - .iter() - .enumerate() - .filter(|(_, port)| port.name == p.name) - .map(|(i, _)| i) - .collect(); - match m.as_slice() { - [i] => Ok((p.node, *i)), - [] => Err(BuildError::UnknownInPort { node: p.node, name: p.name.to_string() }), - _ => Err(BuildError::AmbiguousInPort { node: p.node, name: p.name.to_string() }), + match resolve_input_slot(schema, p.name) { + Ok(i) => Ok((p.node, i)), + Err(PortResolveError::Unknown) => { + Err(BuildError::UnknownInPort { node: p.node, name: p.name.to_string() }) + } + Err(PortResolveError::Ambiguous) => { + Err(BuildError::AmbiguousInPort { node: p.node, name: p.name.to_string() }) + } } } @@ -183,21 +180,59 @@ impl GraphBuilder { .schemas .get(p.node) .ok_or(BuildError::BadHandle { node: p.node })?; - let m: Vec = schema - .output - .iter() - .enumerate() - .filter(|(_, f)| f.name == p.name) - .map(|(i, _)| i) - .collect(); - match m.as_slice() { - [i] => Ok(*i), - [] => Err(BuildError::UnknownOutPort { node: p.node, name: p.name.to_string() }), - _ => Err(BuildError::AmbiguousOutPort { node: p.node, name: p.name.to_string() }), + match resolve_output_field(schema, p.name) { + Ok(i) => Ok(i), + Err(PortResolveError::Unknown) => { + Err(BuildError::UnknownOutPort { node: p.node, name: p.name.to_string() }) + } + Err(PortResolveError::Ambiguous) => { + Err(BuildError::AmbiguousOutPort { node: p.node, name: p.name.to_string() }) + } } } } +/// A name→index resolution outcome, shared by `GraphBuilder` (mapped to +/// `BuildError`) and `GraphSession` (mapped to `OpError`). Over `&str`, so it +/// serves both `&'static str` literals and runtime document names. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum PortResolveError { + Unknown, + Ambiguous, +} + +/// Resolve an input-port name to its slot index by exactly-one-match. +pub(crate) fn resolve_input_slot(schema: &NodeSchema, name: &str) -> Result { + let m: Vec = schema + .inputs + .iter() + .enumerate() + .filter(|(_, port)| port.name == name) + .map(|(i, _)| i) + .collect(); + match m.as_slice() { + [i] => Ok(*i), + [] => Err(PortResolveError::Unknown), + _ => Err(PortResolveError::Ambiguous), + } +} + +/// Resolve an output-field name to its field index by exactly-one-match. +pub(crate) fn resolve_output_field(schema: &NodeSchema, name: &str) -> Result { + let m: Vec = schema + .output + .iter() + .enumerate() + .filter(|(_, f)| f.name == name) + .map(|(i, _)| i) + .collect(); + match m.as_slice() { + [i] => Ok(*i), + [] => Err(PortResolveError::Unknown), + _ => Err(PortResolveError::Ambiguous), + } +} + #[cfg(test)] mod tests { use super::{BuildError, GraphBuilder}; @@ -297,6 +332,19 @@ mod tests { ); } + #[test] + fn shared_port_resolution_matches_exactly_one() { + use super::{resolve_input_slot, resolve_output_field, PortResolveError}; + use crate::BlueprintNode; + use aura_std::Sub; + let schema = BlueprintNode::from(Sub::builder()).signature(); // lhs, rhs -> value + assert_eq!(resolve_input_slot(&schema, "lhs"), Ok(0)); + assert_eq!(resolve_input_slot(&schema, "rhs"), Ok(1)); + assert_eq!(resolve_input_slot(&schema, "nope"), Err(PortResolveError::Unknown)); + assert_eq!(resolve_output_field(&schema, "value"), Ok(0)); + assert_eq!(resolve_output_field(&schema, "nope"), Err(PortResolveError::Unknown)); + } + #[test] fn unknown_in_port_is_reported_at_build() { let mut g = GraphBuilder::new("x"); diff --git a/docs/plans/0088-construction-op-script.md b/docs/plans/0088-construction-op-script.md index 99408ef..b88aa16 100644 --- a/docs/plans/0088-construction-op-script.md +++ b/docs/plans/0088-construction-op-script.md @@ -310,17 +310,30 @@ crates/aura-core/src/node.rs`): #[test] fn try_bind_ok_and_typed_errors() { use super::BindOpError; - use aura_std::Sma; // SMA has one i64 param `length` + // A probe with one i64 param `length` (a real std node like `Sma` would + // pull in aura-std, but aura-core cannot dev-depend on it without a + // dev-cycle that compiles aura-core twice — incompatible `Scalar` types). + let probe = || { + PrimitiveBuilder::new( + "Probe", + NodeSchema { + inputs: vec![], + output: vec![], + params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }], + }, + |_| Box::new(Bare), + ) + }; // ok: exact name, matching kind - assert!(Sma::builder().try_bind("length", Scalar::i64(3)).is_ok()); + assert!(probe().try_bind("length", Scalar::i64(3)).is_ok()); // unknown param name assert_eq!( - Sma::builder().try_bind("nope", Scalar::i64(3)).err(), + probe().try_bind("nope", Scalar::i64(3)).err(), Some(BindOpError::UnknownParam("nope".into())) ); // kind mismatch (f64 into an i64 param) assert_eq!( - Sma::builder().try_bind("length", Scalar::f64(3.0)).err(), + probe().try_bind("length", Scalar::f64(3.0)).err(), Some(BindOpError::KindMismatch { param: "length".into(), expected: ScalarKind::I64, @@ -332,6 +345,12 @@ fn try_bind_ok_and_typed_errors() { > `.err()` not `.unwrap_err()`: the `Ok` type `PrimitiveBuilder` is not `Debug` > (it holds a build closure), mirroring `builder.rs`'s tests. +> +> **Plan correction (orchestrator, post-block):** the original Step-1 test +> referenced `aura_std::Sma`, infeasible inside `aura-core` (aura-std depends on +> aura-core; the reverse edge is a dependency cycle). Replaced with a local +> `PrimitiveBuilder::new` probe carrying identical assertions — `Bare` is the +> test module's existing zero-output node. - [ ] **Step 2: Run test to verify it fails**