feat(0088): §A shared gate predicates — edge_kind_check, resolution helpers, try_bind

Iteration-1 §A of the construction service (#157): the surface-agnostic shared
predicates the eager op-script path and the holistic finalize gates both call —
no second validator (C24).

- edge_kind_check (blueprint.rs): the per-edge producer/consumer kind check,
  lifted verbatim out of validate_wiring's edge loop into a pub(crate) fn that
  validate_wiring now calls — behaviour-preserving (same Bootstrap(KindMismatch)
  variant; the existing compile-time gate test stays green).
- resolve_input_slot / resolve_output_field (builder.rs): the exactly-one-match
  name→index resolution extracted as pub(crate) fns over (&NodeSchema, &str), so
  the runtime-String op-script names share GraphBuilder's resolution; GraphBuilder
  delegates and maps to the unchanged BuildError variants.
- try_bind + BindOpError (aura-core node.rs): the fallible Result twin of bind
  (bind keeps its panic contract and pinned messages verbatim; try_bind is a
  separate method reporting the same three conditions as values).
- check_param_namespace_injective + validate_wiring widened to pub(crate) for the
  finalize-stage reuse by the upcoming GraphSession::finish (§B).

Partial iteration: §B (the GraphSession/Op/replay surface + introspection) and
the §A→§B integration land next (plan Tasks 4-8). compile_with_params /
check_ports_connected are behaviourally unchanged. Full suite green; clippy clean.

Plan correction folded in: Task 3's test originally referenced aura_std::Sma —
infeasible inside aura-core (aura-std depends on aura-core; the reverse edge is a
dependency cycle). Adapted to a local PrimitiveBuilder probe with identical
assertions.

refs #157
This commit is contained in:
2026-06-29 18:38:34 +02:00
parent a5b887a955
commit ea1ca32dbd
5 changed files with 241 additions and 37 deletions
+1 -1
View File
@@ -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};
+94
View File
@@ -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<Self, BindOpError> {
let matches: Vec<usize> = 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<usize> = 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)]
+53 -10
View File
@@ -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
+70 -22
View File
@@ -162,17 +162,14 @@ impl GraphBuilder {
.schemas
.get(p.node)
.ok_or(BuildError::BadHandle { node: p.node })?;
let m: Vec<usize> = 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<usize> = 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<usize, PortResolveError> {
let m: Vec<usize> = 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<usize, PortResolveError> {
let m: Vec<usize> = 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");
+23 -4
View File
@@ -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**