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
+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");