feat(aura-engine): Op::Use splices a registered blueprint; open patterns build
The construction session gains the use op: Op::Use { ref_id, name, bind }
resolves through a second injected closure (subgraph: &dyn Fn(&str) ->
Option<Composite>, mirroring vocab's closed-lookup posture — the engine never
touches the store) and pushes the fetched composite as BlueprintNode::Composite,
renamed to the instance identifier (names are identity-blind debug symbols),
with path-qualified binds applied at splice time. Port resolution, kind checks,
the eager cycle gate, and the holistic finish gates all walk the existing
Composite arm — the session storage already carried it; the
"sessions only ever add primitives" unreachable! is retired by construction.
Open patterns (ratified mid-cycle, #317 comment 4627): finish() drops the
root-role gate; compile/bootstrap keep it. An op-script's explicit input roles
are a pattern's formal parameters — buildable, registerable, splicable; running
one standalone still refuses at bootstrap. The pinned finish-refusal test flips
to assert the new split (finish OK, compile refuses). No shipped fixture used
the input op; no corpus behaviour moves.
Gang integrity at the use seam (review finding): binding a ganged member's raw
path refused via the new BindOpError::AlreadyGanged { param, gang } (aura-core)
instead of silently de-fusing the gang — membership keyed on the same
(node, original_pos) coordinate collect_params/check_gangs use, per nesting
level. The gang's public knob stays unbindable at the seam (recorded residue).
Correctness oracle: use_op_splices_byte_identical_to_the_rust_built_twin pins
blueprint_to_json byte-equality against the GraphBuilder twin AND a
bit-identical recorded run (C1). Seven further use_op_* tests cover the fault
surfaces; open_input_pattern_finishes_registers_shaped_and_splices covers the
open-pattern round trip build -> serialize -> reload -> splice -> run.
No blueprint-format change: the serialized form already nests composites
recursively; the golden byte pin stays untouched.
refs #317
This commit is contained in:
@@ -324,7 +324,10 @@ impl PrimitiveBuilder {
|
|||||||
/// A fallible-bind fault — the `Result` twin of `bind`'s panics, for the
|
/// A fallible-bind fault — the `Result` twin of `bind`'s panics, for the
|
||||||
/// data-level construction surface (`GraphSession`, #157). `bind` keeps its
|
/// data-level construction surface (`GraphSession`, #157). `bind` keeps its
|
||||||
/// panic contract (and its pinned messages); `try_bind` reports the same three
|
/// panic contract (and its pinned messages); `try_bind` reports the same three
|
||||||
/// conditions as values.
|
/// conditions as values. `AlreadyGanged` is a fourth condition `try_bind`
|
||||||
|
/// itself never raises — only `Composite::bind_path`'s gang guard (#317
|
||||||
|
/// follow-up) does, refusing a ganged member's raw path at the use seam
|
||||||
|
/// before it silently de-fuses the gang.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub enum BindOpError {
|
pub enum BindOpError {
|
||||||
/// No still-open param has this name.
|
/// No still-open param has this name.
|
||||||
@@ -333,6 +336,12 @@ pub enum BindOpError {
|
|||||||
AmbiguousParam(String),
|
AmbiguousParam(String),
|
||||||
/// The value's kind does not equal the param's declared kind.
|
/// The value's kind does not equal the param's declared kind.
|
||||||
KindMismatch { param: String, expected: ScalarKind, got: ScalarKind },
|
KindMismatch { param: String, expected: ScalarKind, got: ScalarKind },
|
||||||
|
/// `param` (the full qualified path) is a member of the gang named
|
||||||
|
/// `gang` — binding it directly would freeze that one member while the
|
||||||
|
/// gang's public knob keeps driving its siblings. Bind the gang's own
|
||||||
|
/// public knob instead (accepted residue: unbindable at the use seam,
|
||||||
|
/// #317).
|
||||||
|
AlreadyGanged { param: String, gang: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A node's declared interface: its inputs (in order) and its output record — an
|
/// A node's declared interface: its inputs (in order) and its output record — an
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
//! C23) and no external dependency (C16).
|
//! C23) and no external dependency (C16).
|
||||||
|
|
||||||
use aura_core::{
|
use aura_core::{
|
||||||
Cell, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, Scalar, ScalarKind,
|
BindOpError, Cell, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, Scalar,
|
||||||
|
ScalarKind,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::harness::{BootstrapError, Edge, FlatGraph, FlatTap, Harness, SourceSpec, Target};
|
use crate::harness::{BootstrapError, Edge, FlatGraph, FlatTap, Harness, SourceSpec, Target};
|
||||||
@@ -240,6 +241,37 @@ impl Composite {
|
|||||||
pub fn doc(&self) -> Option<&str> {
|
pub fn doc(&self) -> Option<&str> {
|
||||||
self.doc.as_deref()
|
self.doc.as_deref()
|
||||||
}
|
}
|
||||||
|
/// Rename this composite's render symbol in place (#317, `Op::Use`): the
|
||||||
|
/// interior — nodes, edges, roles, output — is untouched; only `name()`
|
||||||
|
/// changes, so a fetched, registered subgraph renders (and path-prefixes
|
||||||
|
/// its `param_space()`, via `collect_params`'s composite arm) under the
|
||||||
|
/// caller-chosen instance identifier instead of its own authored name.
|
||||||
|
pub(crate) fn renamed(mut self, name: impl Into<String>) -> Composite {
|
||||||
|
self.name = name.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
/// Apply one path-qualified bind after `Op::Use`'s splice (#317): the
|
||||||
|
/// same underlying mechanic every bind goes through
|
||||||
|
/// (`PrimitiveBuilder::try_bind`), reached by walking `path` down through
|
||||||
|
/// nested composite frames by node/composite-name prefix — the segmentation
|
||||||
|
/// `reopen_in` (#246) also walks, but binding an open param instead of
|
||||||
|
/// unbinding a bound one. A path matching no node at any level is
|
||||||
|
/// `BindOpError::UnknownParam(path)` (the WHOLE qualified path — no open
|
||||||
|
/// param lives there); a path that reaches a primitive but whose leaf
|
||||||
|
/// param name itself is unknown/ambiguous/ill-kinded rewrites `try_bind`'s
|
||||||
|
/// own fault to carry the full qualified path in place of the bare leaf
|
||||||
|
/// name, so every fault out of this walk names the same thing: the path
|
||||||
|
/// the caller wrote. A path landing on a GANGED member's own param is
|
||||||
|
/// `BindOpError::AlreadyGanged` (review finding, #317 follow-up) — binding
|
||||||
|
/// it directly would silently de-fuse the gang (the member frozen while
|
||||||
|
/// the gang's public knob keeps driving its siblings); the gang's own
|
||||||
|
/// public knob stays unbindable at the use seam (accepted residue). On
|
||||||
|
/// `Err` `self` is dropped (consumed) — the same discard-on-ambiguity
|
||||||
|
/// posture `reopen` uses.
|
||||||
|
pub(crate) fn bind_path(mut self, path: &str, value: Scalar) -> Result<Composite, BindOpError> {
|
||||||
|
bind_path_in(&mut self.nodes, &self.gangs, path, path, value)?;
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
/// Install declared measurement taps (the output-side twin of `input_roles`).
|
/// Install declared measurement taps (the output-side twin of `input_roles`).
|
||||||
/// Fluent, mirroring `with_doc`. Empty by default.
|
/// Fluent, mirroring `with_doc`. Empty by default.
|
||||||
pub fn with_taps(mut self, taps: Vec<Tap>) -> Self {
|
pub fn with_taps(mut self, taps: Vec<Tap>) -> Self {
|
||||||
@@ -1097,6 +1129,92 @@ fn reopen_in(items: &mut [BlueprintNode], path: &str) -> usize {
|
|||||||
hits
|
hits
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Recursive mutable walk for `Composite::bind_path` (#317): mirrors
|
||||||
|
/// `reopen_in`'s node/composite-name prefix segmentation, but BINDS an open
|
||||||
|
/// param (`PrimitiveBuilder::try_bind`) instead of unbinding a bound one.
|
||||||
|
/// `full_path` is the whole original path (named in every fault this
|
||||||
|
/// produces); `rest` is the remaining suffix at this recursion depth. `gangs`
|
||||||
|
/// is the CURRENT frame's gang table (mirrors `collect_params`'s `gangs`
|
||||||
|
/// parameter) — before a matched primitive's leaf param reaches `try_bind`,
|
||||||
|
/// its (node, original-pos) is checked against every gang member; a hit
|
||||||
|
/// refuses with `BindOpError::AlreadyGanged` rather than silently freezing
|
||||||
|
/// the member out from under its gang's public knob (review finding,
|
||||||
|
/// #317 follow-up). Uses `Vec::remove`/`insert` (not `&mut` in place) because
|
||||||
|
/// `try_bind` consumes its receiver — the same reason `lower_items` moves
|
||||||
|
/// `BlueprintNode`s by value rather than mutating through a reference.
|
||||||
|
fn bind_path_in(
|
||||||
|
items: &mut Vec<BlueprintNode>,
|
||||||
|
gangs: &[Gang],
|
||||||
|
full_path: &str,
|
||||||
|
rest: &str,
|
||||||
|
value: Scalar,
|
||||||
|
) -> Result<(), BindOpError> {
|
||||||
|
// The prefix this frame has already consumed off `full_path` (composite
|
||||||
|
// names and up) — used to qualify a gang's public name the same way
|
||||||
|
// `collect_params` does, so the refusal below names the SAME address
|
||||||
|
// `param_space()` would show for that gang.
|
||||||
|
let prefix = &full_path[..full_path.len() - rest.len()];
|
||||||
|
for i in 0..items.len() {
|
||||||
|
let child_rest = match &items[i] {
|
||||||
|
BlueprintNode::Primitive(b) => rest.strip_prefix(&format!("{}.", b.node_name())),
|
||||||
|
BlueprintNode::Composite(c) => rest.strip_prefix(&format!("{}.", c.name())),
|
||||||
|
};
|
||||||
|
let Some(child_rest) = child_rest else { continue };
|
||||||
|
let child_rest = child_rest.to_string();
|
||||||
|
// Gang guard (#317 follow-up review finding): a raw path landing on a
|
||||||
|
// GANGED member's own param must not silently freeze it while the
|
||||||
|
// gang's public knob keeps driving its siblings — refuse by
|
||||||
|
// identifier before ever reaching `try_bind`. The gang's own public
|
||||||
|
// knob staying unbindable at the use seam is accepted residue.
|
||||||
|
if let BlueprintNode::Primitive(b) = &items[i]
|
||||||
|
&& let Some(pos) = b.params().iter().position(|p| p.name == child_rest).map(|idx| b.original_pos(idx))
|
||||||
|
&& let Some(g) = gangs.iter().find(|g| g.members.iter().any(|m| m.node == i && m.pos == pos))
|
||||||
|
{
|
||||||
|
return Err(BindOpError::AlreadyGanged {
|
||||||
|
param: full_path.to_string(),
|
||||||
|
gang: format!("{prefix}{}", g.name),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let item = items.remove(i);
|
||||||
|
return match item {
|
||||||
|
BlueprintNode::Primitive(b) => match b.try_bind(&child_rest, value) {
|
||||||
|
Ok(b2) => {
|
||||||
|
items.insert(i, BlueprintNode::Primitive(b2));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => Err(qualify_bind_error(e, full_path)),
|
||||||
|
},
|
||||||
|
BlueprintNode::Composite(mut c) => {
|
||||||
|
let r = bind_path_in(&mut c.nodes, &c.gangs, full_path, &child_rest, value);
|
||||||
|
items.insert(i, BlueprintNode::Composite(c));
|
||||||
|
r
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// no node at any level matched `rest`'s leading segment: no open param
|
||||||
|
// lives at this path.
|
||||||
|
Err(BindOpError::UnknownParam(full_path.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewrite a leaf `try_bind` fault to carry the FULL qualified path (#317)
|
||||||
|
/// in place of the bare leaf param name `try_bind` only ever sees (it has no
|
||||||
|
/// visibility past the one primitive it was called on).
|
||||||
|
fn qualify_bind_error(e: BindOpError, full_path: &str) -> BindOpError {
|
||||||
|
match e {
|
||||||
|
BindOpError::UnknownParam(_) => BindOpError::UnknownParam(full_path.to_string()),
|
||||||
|
BindOpError::AmbiguousParam(_) => BindOpError::AmbiguousParam(full_path.to_string()),
|
||||||
|
BindOpError::KindMismatch { expected, got, .. } => {
|
||||||
|
BindOpError::KindMismatch { param: full_path.to_string(), expected, got }
|
||||||
|
}
|
||||||
|
// `try_bind` never raises this one (it has no gang awareness) — the
|
||||||
|
// gang guard above constructs it directly, already fully qualified.
|
||||||
|
// Kept here only so this match stays total over `BindOpError`.
|
||||||
|
BindOpError::AlreadyGanged { gang, .. } => {
|
||||||
|
BindOpError::AlreadyGanged { param: full_path.to_string(), gang }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Read-only twin of `collect_params` over the BOUND surface (#246): same
|
/// Read-only twin of `collect_params` over the BOUND surface (#246): same
|
||||||
/// recursion shape and prefix rules, but enumerating `bound_params()` (with
|
/// recursion shape and prefix rules, but enumerating `bound_params()` (with
|
||||||
/// values) instead of the open `params()`. Gangs are irrelevant here — a gang
|
/// values) instead of the open `params()`. Gangs are irrelevant here — a gang
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
//! (#157). A `GraphSession` applies `Op`s one at a time, running the
|
//! (#157). A `GraphSession` applies `Op`s one at a time, running the
|
||||||
//! eagerly-decidable checks (name resolution, edge kind-match, the >1-cover
|
//! eagerly-decidable checks (name resolution, edge kind-match, the >1-cover
|
||||||
//! double-wire arm, param bind) at the offending op; the only-at-end-decidable
|
//! double-wire arm, param bind) at the offending op; the only-at-end-decidable
|
||||||
//! checks (0-cover totality, param-namespace injectivity, root-role boundness)
|
//! checks (0-cover totality, param-namespace injectivity) run holistically in
|
||||||
//! run holistically in `finish`. Both cadences call the SAME §A predicates — no
|
//! `finish`. Both cadences call the SAME §A predicates — no second validator
|
||||||
//! second validator (C24). The node vocabulary is an injected closed resolver
|
//! (C24). Root-role boundness is a *runnability* gate, not a construction gate:
|
||||||
|
//! `finish()` does not run it (#317, open patterns), so an op-script may
|
||||||
|
//! finish with an open root role; only `compile`/bootstrap enforce it. The
|
||||||
|
//! node vocabulary is an injected closed resolver
|
||||||
//! (`Fn(&str)->Option<PrimitiveBuilder>`), so the engine stays domain-free
|
//! (`Fn(&str)->Option<PrimitiveBuilder>`), so the engine stays domain-free
|
||||||
//! (invariant 9).
|
//! (invariant 9).
|
||||||
|
|
||||||
@@ -13,8 +16,8 @@ use std::collections::{HashMap, HashSet};
|
|||||||
use aura_core::{BindOpError, NodeSchema, PrimitiveBuilder, Scalar, ScalarKind};
|
use aura_core::{BindOpError, NodeSchema, PrimitiveBuilder, Scalar, ScalarKind};
|
||||||
|
|
||||||
use crate::blueprint::{
|
use crate::blueprint::{
|
||||||
check_param_namespace_injective, check_root_roles_bound, edge_kind_check, validate_wiring,
|
check_param_namespace_injective, edge_kind_check, validate_wiring, BlueprintNode, Composite,
|
||||||
BlueprintNode, Composite, Gang, GangMember, OutField, Role, Tap, TapWire,
|
Gang, GangMember, OutField, Role, Tap, TapWire,
|
||||||
};
|
};
|
||||||
use crate::builder::{resolve_input_slot, resolve_output_field, PortResolveError};
|
use crate::builder::{resolve_input_slot, resolve_output_field, PortResolveError};
|
||||||
use crate::harness::{Edge, Target};
|
use crate::harness::{Edge, Target};
|
||||||
@@ -46,6 +49,15 @@ pub enum Op {
|
|||||||
/// twin of the builder's `.doc(...)`. At most one per script; a second
|
/// twin of the builder's `.doc(...)`. At most one per script; a second
|
||||||
/// is a fault (a declarative script carries no last-wins ambiguity).
|
/// is a fault (a declarative script carries no last-wins ambiguity).
|
||||||
Doc { text: String },
|
Doc { text: String },
|
||||||
|
/// Splice a registered blueprint into the building graph as a nested
|
||||||
|
/// composite under an instance identifier (#317). `ref_id` is the full
|
||||||
|
/// content id the injected `subgraph` resolver is keyed by — the engine
|
||||||
|
/// never sees a label, a content-id prefix, or the registry (CLI-side
|
||||||
|
/// resolution happens at DTO conversion, before replay). `name` is the
|
||||||
|
/// instance identifier (`None` -> the fetched composite's own `name()`);
|
||||||
|
/// `bind` is applied path-qualified (e.g. `"sma.length"`), after the
|
||||||
|
/// splice.
|
||||||
|
Use { ref_id: String, name: Option<String>, bind: Vec<(String, Scalar)> },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A per-op construction fault, by-identifier so the cause names the op.
|
/// A per-op construction fault, by-identifier so the cause names the op.
|
||||||
@@ -85,8 +97,6 @@ pub enum OpError {
|
|||||||
/// Holistic role-kind fault, by-identifier: a root input role fans into slots
|
/// Holistic role-kind fault, by-identifier: a root input role fans into slots
|
||||||
/// of differing scalar kinds.
|
/// of differing scalar kinds.
|
||||||
RoleKindMismatch { role: String },
|
RoleKindMismatch { role: String },
|
||||||
/// Holistic root fault, by-identifier: a root input role has no bound source.
|
|
||||||
UnboundRootRole { role: String },
|
|
||||||
/// Gang members must share one scalar kind.
|
/// Gang members must share one scalar kind.
|
||||||
GangKindMismatch { member: String, expected: ScalarKind, got: ScalarKind },
|
GangKindMismatch { member: String, expected: ScalarKind, got: ScalarKind },
|
||||||
/// The param is already claimed by an earlier gang.
|
/// The param is already claimed by an earlier gang.
|
||||||
@@ -98,6 +108,11 @@ pub enum OpError {
|
|||||||
Incomplete(CompileError),
|
Incomplete(CompileError),
|
||||||
/// A second `doc` op — the meaning line is declared at most once.
|
/// A second `doc` op — the meaning line is declared at most once.
|
||||||
DuplicateDoc,
|
DuplicateDoc,
|
||||||
|
/// `Op::Use`'s injected `subgraph` resolver found no composite for
|
||||||
|
/// `ref_id` (#317) — by-identifier. Unreachable from the CLI (which
|
||||||
|
/// resolves and fetches before replay, refusing there on a miss), but
|
||||||
|
/// total: a bare `replay`/`GraphSession` caller can still hit it.
|
||||||
|
UnknownSubgraph { ref_id: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A per-op-fallible blueprint accumulator. Holds the same interior data a
|
/// A per-op-fallible blueprint accumulator. Holds the same interior data a
|
||||||
@@ -106,6 +121,13 @@ pub enum OpError {
|
|||||||
pub struct GraphSession<'v> {
|
pub struct GraphSession<'v> {
|
||||||
name: String,
|
name: String,
|
||||||
vocab: &'v dyn Fn(&str) -> Option<PrimitiveBuilder>,
|
vocab: &'v dyn Fn(&str) -> Option<PrimitiveBuilder>,
|
||||||
|
/// Second injected resolver (#317), mirroring `vocab`'s closed-lookup
|
||||||
|
/// posture: `Op::Use` fetches a registered blueprint by full content id.
|
||||||
|
/// The engine stays store-free — CLI-side resolution (label/prefix,
|
||||||
|
/// C29 doc gate, the store fetch itself) happens at DTO conversion,
|
||||||
|
/// before replay; this closure is a pure lookup into an already-fetched
|
||||||
|
/// id->`Composite` cache. Build-free introspection paths pass `&|_| None`.
|
||||||
|
subgraph: &'v dyn Fn(&str) -> Option<Composite>,
|
||||||
nodes: Vec<BlueprintNode>,
|
nodes: Vec<BlueprintNode>,
|
||||||
schemas: Vec<NodeSchema>,
|
schemas: Vec<NodeSchema>,
|
||||||
ids: Vec<String>,
|
ids: Vec<String>,
|
||||||
@@ -124,11 +146,17 @@ pub struct GraphSession<'v> {
|
|||||||
|
|
||||||
impl<'v> GraphSession<'v> {
|
impl<'v> GraphSession<'v> {
|
||||||
/// Start a session for a composite named `name`, resolving node types through
|
/// Start a session for a composite named `name`, resolving node types through
|
||||||
/// the injected closed vocabulary `vocab`.
|
/// the injected closed vocabulary `vocab` and registered-blueprint splices
|
||||||
pub fn new(name: impl Into<String>, vocab: &'v dyn Fn(&str) -> Option<PrimitiveBuilder>) -> Self {
|
/// (`Op::Use`, #317) through the injected `subgraph` lookup.
|
||||||
|
pub fn new(
|
||||||
|
name: impl Into<String>,
|
||||||
|
vocab: &'v dyn Fn(&str) -> Option<PrimitiveBuilder>,
|
||||||
|
subgraph: &'v dyn Fn(&str) -> Option<Composite>,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
vocab,
|
vocab,
|
||||||
|
subgraph,
|
||||||
nodes: Vec::new(),
|
nodes: Vec::new(),
|
||||||
schemas: Vec::new(),
|
schemas: Vec::new(),
|
||||||
ids: Vec::new(),
|
ids: Vec::new(),
|
||||||
@@ -165,9 +193,47 @@ impl<'v> GraphSession<'v> {
|
|||||||
self.doc = Some(text);
|
self.doc = Some(text);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
Op::Use { ref_id, name, bind } => self.use_subgraph(ref_id, name, bind),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `Op::Use` (#317): fetch a registered blueprint through the injected
|
||||||
|
/// `subgraph` resolver, rename it to the instance identifier, apply its
|
||||||
|
/// path-qualified `bind` entries, and push it as an ordinary
|
||||||
|
/// `BlueprintNode::Composite` — sharing the node-identifier namespace
|
||||||
|
/// with every `Add`ed primitive (so an instance and a primitive collide
|
||||||
|
/// the same way two primitives do). A miss on `ref_id` is a by-identifier
|
||||||
|
/// `UnknownSubgraph`; a `bind` path with no matching open param anywhere
|
||||||
|
/// in the instance reuses the existing `BadParam` bind-fault shape,
|
||||||
|
/// naming the instance (`node`) and the qualified within-instance path
|
||||||
|
/// (`err`'s carried name) — the pair spells out the full qualified path.
|
||||||
|
fn use_subgraph(
|
||||||
|
&mut self,
|
||||||
|
ref_id: String,
|
||||||
|
name: Option<String>,
|
||||||
|
bind: Vec<(String, Scalar)>,
|
||||||
|
) -> Result<(), OpError> {
|
||||||
|
let composite = (self.subgraph)(&ref_id).ok_or_else(|| OpError::UnknownSubgraph { ref_id: ref_id.clone() })?;
|
||||||
|
let id = name.unwrap_or_else(|| composite.name().to_string());
|
||||||
|
if self.names.contains_key(&id) {
|
||||||
|
return Err(OpError::DuplicateIdentifier(id));
|
||||||
|
}
|
||||||
|
let mut composite = composite.renamed(&id);
|
||||||
|
for (path, value) in bind {
|
||||||
|
composite = composite
|
||||||
|
.bind_path(&path, value)
|
||||||
|
.map_err(|err| OpError::BadParam { node: id.clone(), err })?;
|
||||||
|
}
|
||||||
|
let node = BlueprintNode::Composite(composite);
|
||||||
|
let schema = node.signature();
|
||||||
|
let idx = self.nodes.len();
|
||||||
|
self.names.insert(id.clone(), idx);
|
||||||
|
self.ids.push(id);
|
||||||
|
self.nodes.push(node);
|
||||||
|
self.schemas.push(schema);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn add_role(&mut self, role: String, kind: Option<ScalarKind>) -> Result<(), OpError> {
|
fn add_role(&mut self, role: String, kind: Option<ScalarKind>) -> Result<(), OpError> {
|
||||||
if self.role_names.contains_key(&role) {
|
if self.role_names.contains_key(&role) {
|
||||||
return Err(OpError::DuplicateRole(role));
|
return Err(OpError::DuplicateRole(role));
|
||||||
@@ -368,7 +434,16 @@ impl<'v> GraphSession<'v> {
|
|||||||
let (node_name, param_name) = Self::split_port(port)?;
|
let (node_name, param_name) = Self::split_port(port)?;
|
||||||
let ti = self.node_index(&node_name)?;
|
let ti = self.node_index(&node_name)?;
|
||||||
let BlueprintNode::Primitive(b) = &self.nodes[ti] else {
|
let BlueprintNode::Primitive(b) = &self.nodes[ti] else {
|
||||||
unreachable!("sessions only ever add primitives")
|
// #317: `Op::Use` makes a session node legitimately a
|
||||||
|
// Composite, not just a Primitive — reachable now, not a
|
||||||
|
// defect. A gang fuses a PRIMITIVE's raw (name, pos) param
|
||||||
|
// slot; an instance has no such flat slot (its params are
|
||||||
|
// nested/path-qualified), so this is the ordinary
|
||||||
|
// no-such-open-param refusal, not a panic.
|
||||||
|
return Err(OpError::BadParam {
|
||||||
|
node: node_name,
|
||||||
|
err: BindOpError::UnknownParam(param_name),
|
||||||
|
});
|
||||||
};
|
};
|
||||||
let hits: Vec<usize> = b
|
let hits: Vec<usize> = b
|
||||||
.params()
|
.params()
|
||||||
@@ -426,13 +501,17 @@ impl<'v> GraphSession<'v> {
|
|||||||
open
|
open
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Assemble the `Composite` and run the holistic gates (totality,
|
/// Assemble the `Composite` and run the holistic construction gates (totality,
|
||||||
/// param-namespace injectivity, root-role boundness) — the SAME engine
|
/// param-namespace injectivity) — the SAME engine predicates the eager path
|
||||||
/// predicates the eager path shares, now at end-of-document cadence. The
|
/// shares, now at end-of-document cadence. Root-role boundness is
|
||||||
/// index-carrying `CompileError`s the gates return are translated back to the
|
/// deliberately NOT checked here (#317, open patterns): a `finish()`ed
|
||||||
/// surface's by-identifier `OpError` variants (slot/role names via the session's
|
/// composite may still carry an open root role, declaring a reusable
|
||||||
/// retained `ids`/`schemas` and the composite's roles); a fault with no
|
/// pattern's formal parameters; only `compile`/bootstrap (the runnability
|
||||||
/// by-identifier mapping falls through to `OpError::Incomplete`.
|
/// gate) refuse an unbound root role. The index-carrying `CompileError`s the
|
||||||
|
/// gates return are translated back to the surface's by-identifier `OpError`
|
||||||
|
/// variants (slot/role names via the session's retained `ids`/`schemas` and
|
||||||
|
/// the composite's roles); a fault with no by-identifier mapping falls
|
||||||
|
/// through to `OpError::Incomplete`.
|
||||||
pub fn finish(self) -> Result<Composite, OpError> {
|
pub fn finish(self) -> Result<Composite, OpError> {
|
||||||
let roles: Vec<Role> = self
|
let roles: Vec<Role> = self
|
||||||
.roles
|
.roles
|
||||||
@@ -458,25 +537,22 @@ impl<'v> GraphSession<'v> {
|
|||||||
},
|
},
|
||||||
other => OpError::Incomplete(other),
|
other => OpError::Incomplete(other),
|
||||||
})?;
|
})?;
|
||||||
check_root_roles_bound(c.input_roles()).map_err(|e| match e {
|
|
||||||
CompileError::UnboundRootRole { role } => OpError::UnboundRootRole {
|
|
||||||
role: c.input_roles()[role].name.clone(),
|
|
||||||
},
|
|
||||||
other => OpError::Incomplete(other),
|
|
||||||
})?;
|
|
||||||
Ok(c)
|
Ok(c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replay a document of ops from scratch, stopping at the first failing op and
|
/// Replay a document of ops from scratch, stopping at the first failing op and
|
||||||
/// naming it by `(op_index, OpError)`; a holistic finalize fault is attributed to
|
/// naming it by `(op_index, OpError)`; a holistic finalize fault is attributed to
|
||||||
/// index `ops.len()` (the implicit finalize step).
|
/// index `ops.len()` (the implicit finalize step). `subgraph` is the injected
|
||||||
|
/// registered-blueprint lookup `Op::Use` (#317) resolves splices through — pass
|
||||||
|
/// `&|_| None` when no store is in play.
|
||||||
pub fn replay(
|
pub fn replay(
|
||||||
name: impl Into<String>,
|
name: impl Into<String>,
|
||||||
ops: Vec<Op>,
|
ops: Vec<Op>,
|
||||||
vocab: &dyn Fn(&str) -> Option<PrimitiveBuilder>,
|
vocab: &dyn Fn(&str) -> Option<PrimitiveBuilder>,
|
||||||
|
subgraph: &dyn Fn(&str) -> Option<Composite>,
|
||||||
) -> Result<Composite, (usize, OpError)> {
|
) -> Result<Composite, (usize, OpError)> {
|
||||||
let mut s = GraphSession::new(name, vocab);
|
let mut s = GraphSession::new(name, vocab, subgraph);
|
||||||
let n = ops.len();
|
let n = ops.len();
|
||||||
for (i, op) in ops.into_iter().enumerate() {
|
for (i, op) in ops.into_iter().enumerate() {
|
||||||
s.apply(op).map_err(|e| (i, e))?;
|
s.apply(op).map_err(|e| (i, e))?;
|
||||||
@@ -487,11 +563,12 @@ pub fn replay(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{GraphSession, Op, OpError};
|
use super::{GraphSession, Op, OpError};
|
||||||
|
use crate::blueprint::{BlueprintNode, Composite, Role};
|
||||||
use aura_core::{BindOpError, Scalar, ScalarKind};
|
use aura_core::{BindOpError, Scalar, ScalarKind};
|
||||||
use aura_vocabulary::std_vocabulary;
|
use aura_vocabulary::std_vocabulary;
|
||||||
|
|
||||||
fn session(name: &str) -> GraphSession<'static> {
|
fn session(name: &str) -> GraphSession<'static> {
|
||||||
GraphSession::new(name, &std_vocabulary)
|
GraphSession::new(name, &std_vocabulary, &|_: &str| None)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -752,7 +829,7 @@ mod tests {
|
|||||||
Op::Tap { from: "fast.value".into(), as_name: "fast_ma".into() },
|
Op::Tap { from: "fast.value".into(), as_name: "fast_ma".into() },
|
||||||
Op::Expose { from: "sub.value".into(), as_name: "bias".into() },
|
Op::Expose { from: "sub.value".into(), as_name: "bias".into() },
|
||||||
];
|
];
|
||||||
let flat = super::replay("g", ops, &aura_vocabulary::std_vocabulary)
|
let flat = super::replay("g", ops, &aura_vocabulary::std_vocabulary, &|_: &str| None)
|
||||||
.expect("replays")
|
.expect("replays")
|
||||||
.compile_with_params(&[Scalar::i64(2), Scalar::i64(4)])
|
.compile_with_params(&[Scalar::i64(2), Scalar::i64(4)])
|
||||||
.expect("compiles");
|
.expect("compiles");
|
||||||
@@ -774,7 +851,7 @@ mod tests {
|
|||||||
Op::Connect { from: "b.value".into(), to: "sub.rhs".into() },
|
Op::Connect { from: "b.value".into(), to: "sub.rhs".into() },
|
||||||
Op::Expose { from: "sub.value".into(), as_name: "bias".into() },
|
Op::Expose { from: "sub.value".into(), as_name: "bias".into() },
|
||||||
];
|
];
|
||||||
let c = super::replay("sig", ops, &std_vocabulary).expect("replays");
|
let c = super::replay("sig", ops, &std_vocabulary, &|_: &str| None).expect("replays");
|
||||||
let names: Vec<String> = c.param_space().into_iter().map(|p| p.name).collect();
|
let names: Vec<String> = c.param_space().into_iter().map(|p| p.name).collect();
|
||||||
assert_eq!(names, ["length"]);
|
assert_eq!(names, ["length"]);
|
||||||
}
|
}
|
||||||
@@ -820,7 +897,7 @@ mod tests {
|
|||||||
Op::Feed { role: "trigger".into(), into: vec!["session.trigger".into()] },
|
Op::Feed { role: "trigger".into(), into: vec!["session.trigger".into()] },
|
||||||
Op::Expose { from: "session.bars_since_open".into(), as_name: "bars".into() },
|
Op::Expose { from: "session.bars_since_open".into(), as_name: "bars".into() },
|
||||||
];
|
];
|
||||||
super::replay("session_blueprint", ops, &std_vocabulary)
|
super::replay("session_blueprint", ops, &std_vocabulary, &|_: &str| None)
|
||||||
.expect("the preset resolves, wires and builds through an op-script");
|
.expect("the preset resolves, wires and builds through an op-script");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -922,18 +999,26 @@ mod tests {
|
|||||||
"finalize names the open slot by-identifier, got {err:?}");
|
"finalize names the open slot by-identifier, got {err:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A reserved `Input` root role that is fed but never source-bound finishes with
|
/// #317 (open patterns): `finish()` no longer runs the root-role gate — a
|
||||||
/// the by-identifier `UnboundRootRole` (not a raw role index).
|
/// reserved `Input` root role that is fed but never source-bound now
|
||||||
|
/// finishes cleanly (the composite declares an open formal parameter, the
|
||||||
|
/// pattern's reusable interior). The runnability gate moves entirely to
|
||||||
|
/// `compile`, which refuses the SAME composite with the unchanged
|
||||||
|
/// `CompileError::UnboundRootRole` — the split this cycle ratified: finish
|
||||||
|
/// = construction-complete, compile = runnable.
|
||||||
#[test]
|
#[test]
|
||||||
fn finish_reports_unbound_input_root_role_by_identifier() {
|
fn finish_accepts_an_open_input_role_and_compile_refuses_it() {
|
||||||
let mut s = session("g");
|
let mut s = session("g");
|
||||||
s.apply(Op::Input { role: "ext".into() }).unwrap();
|
s.apply(Op::Input { role: "ext".into() }).unwrap();
|
||||||
s.apply(Op::Add { type_id: "Sub".into(), as_name: Some("sub".into()), bind: vec![] }).unwrap();
|
s.apply(Op::Add { type_id: "Sub".into(), as_name: Some("sub".into()), bind: vec![] }).unwrap();
|
||||||
s.apply(Op::Feed { role: "ext".into(), into: vec!["sub.lhs".into(), "sub.rhs".into()] }).unwrap();
|
s.apply(Op::Feed { role: "ext".into(), into: vec!["sub.lhs".into(), "sub.rhs".into()] }).unwrap();
|
||||||
s.apply(Op::Expose { from: "sub.value".into(), as_name: "out".into() }).unwrap();
|
s.apply(Op::Expose { from: "sub.value".into(), as_name: "out".into() }).unwrap();
|
||||||
let err = s.finish().err().unwrap();
|
let c = s.finish().expect("finish() no longer gates root-role boundness");
|
||||||
assert!(matches!(&err, OpError::UnboundRootRole { role } if role == "ext"),
|
assert_eq!(
|
||||||
"an unbound Input root role finishes by-identifier, got {err:?}");
|
c.compile().err(),
|
||||||
|
Some(crate::CompileError::UnboundRootRole { role: 0 }),
|
||||||
|
"compile() is the runnability gate that refuses the open root role"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A root role fed into two slots of differing scalar kinds (an `f64` `Sub.lhs`
|
/// A root role fed into two slots of differing scalar kinds (an `f64` `Sub.lhs`
|
||||||
@@ -991,7 +1076,7 @@ mod tests {
|
|||||||
let finalize = ops.len();
|
let finalize = ops.len();
|
||||||
// `.err()` not `.unwrap_err()`: the Ok arm `Composite` holds build closures
|
// `.err()` not `.unwrap_err()`: the Ok arm `Composite` holds build closures
|
||||||
// and is not `Debug`.
|
// and is not `Debug`.
|
||||||
let (idx, _err) = super::replay("g", ops, &aura_vocabulary::std_vocabulary)
|
let (idx, _err) = super::replay("g", ops, &aura_vocabulary::std_vocabulary, &|_: &str| None)
|
||||||
.err()
|
.err()
|
||||||
.expect("a cyclic op-list must be rejected (DAG invariant 5 / C9)");
|
.expect("a cyclic op-list must be rejected (DAG invariant 5 / C9)");
|
||||||
// An eager realisation attributes the fault to the closing connect op; a
|
// An eager realisation attributes the fault to the closing connect op; a
|
||||||
@@ -1018,7 +1103,7 @@ mod tests {
|
|||||||
Op::Expose { from: "s.value".into(), as_name: "out".into() },
|
Op::Expose { from: "s.value".into(), as_name: "out".into() },
|
||||||
];
|
];
|
||||||
assert!(
|
assert!(
|
||||||
super::replay("g", ops, &aura_vocabulary::std_vocabulary).is_err(),
|
super::replay("g", ops, &aura_vocabulary::std_vocabulary, &|_: &str| None).is_err(),
|
||||||
"a self-loop op-list must be rejected (DAG invariant 5 / C9)"
|
"a self-loop op-list must be rejected (DAG invariant 5 / C9)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1032,7 +1117,7 @@ mod tests {
|
|||||||
];
|
];
|
||||||
// `.err().unwrap()` rather than `.unwrap_err()`: the Ok arm `Composite` is
|
// `.err().unwrap()` rather than `.unwrap_err()`: the Ok arm `Composite` is
|
||||||
// not `Debug` (it holds build closures), which `unwrap_err` would require.
|
// not `Debug` (it holds build closures), which `unwrap_err` would require.
|
||||||
let err = super::replay("g", ops, &aura_vocabulary::std_vocabulary).err().unwrap();
|
let err = super::replay("g", ops, &aura_vocabulary::std_vocabulary, &|_: &str| None).err().unwrap();
|
||||||
assert_eq!(err, (1, OpError::UnknownNodeType("Nope".into())));
|
assert_eq!(err, (1, OpError::UnknownNodeType("Nope".into())));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1066,7 +1151,7 @@ mod tests {
|
|||||||
Op::Connect { from: "sub.value".into(), to: "bias.signal".into() },
|
Op::Connect { from: "sub.value".into(), to: "bias.signal".into() },
|
||||||
Op::Expose { from: "bias.bias".into(), as_name: "bias".into() },
|
Op::Expose { from: "bias.bias".into(), as_name: "bias".into() },
|
||||||
];
|
];
|
||||||
let replay_flat = super::replay("root", ops, &aura_vocabulary::std_vocabulary)
|
let replay_flat = super::replay("root", ops, &aura_vocabulary::std_vocabulary, &|_: &str| None)
|
||||||
.expect("replay resolves")
|
.expect("replay resolves")
|
||||||
.compile_with_params(¶ms)
|
.compile_with_params(¶ms)
|
||||||
.expect("replay compiles");
|
.expect("replay compiles");
|
||||||
@@ -1113,7 +1198,7 @@ mod tests {
|
|||||||
Op::Connect { from: "b.value".into(), to: "sub.rhs".into() },
|
Op::Connect { from: "b.value".into(), to: "sub.rhs".into() },
|
||||||
Op::Expose { from: "sub.value".into(), as_name: "bias".into() },
|
Op::Expose { from: "sub.value".into(), as_name: "bias".into() },
|
||||||
];
|
];
|
||||||
let replayed = super::replay("g", ops, &std_vocabulary).expect("replays");
|
let replayed = super::replay("g", ops, &std_vocabulary, &|_: &str| None).expect("replays");
|
||||||
|
|
||||||
// (b) the GraphBuilder twin — same adds/feeds/connects/expose + g.gang(...).
|
// (b) the GraphBuilder twin — same adds/feeds/connects/expose + g.gang(...).
|
||||||
let mut g = GraphBuilder::new("g");
|
let mut g = GraphBuilder::new("g");
|
||||||
@@ -1165,7 +1250,7 @@ mod tests {
|
|||||||
Op::Tap { from: "sub.value".into(), as_name: "spread".into() },
|
Op::Tap { from: "sub.value".into(), as_name: "spread".into() },
|
||||||
Op::Expose { from: "sub.value".into(), as_name: "bias".into() },
|
Op::Expose { from: "sub.value".into(), as_name: "bias".into() },
|
||||||
];
|
];
|
||||||
let replayed = super::replay("g", ops, &std_vocabulary).expect("replays");
|
let replayed = super::replay("g", ops, &std_vocabulary, &|_: &str| None).expect("replays");
|
||||||
|
|
||||||
// (b) the GraphBuilder twin — same adds/feeds/connects + g.tap(...) / g.expose(...).
|
// (b) the GraphBuilder twin — same adds/feeds/connects + g.tap(...) / g.expose(...).
|
||||||
let mut g = GraphBuilder::new("g");
|
let mut g = GraphBuilder::new("g");
|
||||||
@@ -1191,4 +1276,353 @@ mod tests {
|
|||||||
assert_eq!(replay_flat.taps, built_flat.taps);
|
assert_eq!(replay_flat.taps, built_flat.taps);
|
||||||
assert_eq!(replay_flat.edges, built_flat.edges);
|
assert_eq!(replay_flat.edges, built_flat.edges);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Op::Use (#317) -----------------------------------------------
|
||||||
|
|
||||||
|
/// A small two-node fixture composite for the `Op::Use` tests (#317): an
|
||||||
|
/// open `price` input role feeds an `Sma`, whose `value` feeds a `Bias`;
|
||||||
|
/// `Bias.bias` re-exports as `out`. Built fresh via `GraphBuilder` on every
|
||||||
|
/// call — mirroring how a CLI-side registry lookup hands back a freshly
|
||||||
|
/// loaded `Composite` each fetch — never shared or cloned (`Composite`
|
||||||
|
/// holds build closures and is not `Clone`).
|
||||||
|
fn use_fixture() -> Composite {
|
||||||
|
use crate::GraphBuilder;
|
||||||
|
use aura_std::Sma;
|
||||||
|
use aura_strategy::Bias;
|
||||||
|
let mut g = GraphBuilder::new("fixture");
|
||||||
|
let sma = g.add(Sma::builder().named("sma"));
|
||||||
|
let bias = g.add(Bias::builder().named("bias"));
|
||||||
|
let price = g.input_role("price");
|
||||||
|
g.feed(price, [sma.input("series")]);
|
||||||
|
g.connect(sma.output("value"), bias.input("signal"));
|
||||||
|
g.expose(bias.output("bias"), "out");
|
||||||
|
g.build().expect("fixture resolves")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrap a sink-free signal (one open input role, one exposed field) in a
|
||||||
|
/// runnable root that records the exposed field via a channel-backed
|
||||||
|
/// `Recorder`, feeds a fixed synthetic price stream through it, and
|
||||||
|
/// returns the recorded trace. Mirrors `blueprint_serde.rs`'s
|
||||||
|
/// `run_recording` test helper (same shape); duplicated here rather than
|
||||||
|
/// shared, since that one is private to its own file.
|
||||||
|
fn run_recording(signal: Composite) -> Vec<(aura_core::Timestamp, Vec<Scalar>)> {
|
||||||
|
use crate::harness::{Edge, Target};
|
||||||
|
use aura_core::Firing;
|
||||||
|
use aura_std::Recorder;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
let root = Composite::new(
|
||||||
|
"h",
|
||||||
|
vec![
|
||||||
|
BlueprintNode::Composite(signal),
|
||||||
|
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![],
|
||||||
|
);
|
||||||
|
// only `bias.scale` is open (every fixture in this module's tests binds
|
||||||
|
// `sma.length` at the use seam), so one F64 param point.
|
||||||
|
let mut h = root.bootstrap_with_params(vec![Scalar::f64(0.5)]).expect("bootstraps");
|
||||||
|
h.run(vec![Box::new(crate::VecSource::new(crate::test_fixtures::synthetic_prices()))]);
|
||||||
|
rx.try_iter().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// C1 twin (#317 headline): an op-script using `Op::Use` splices a
|
||||||
|
/// registered-style subgraph into the building graph byte-identically to
|
||||||
|
/// the same splice authored directly on `GraphBuilder`
|
||||||
|
/// (`g.add(BlueprintNode::Composite(x))` plus the same rename/bind) — the
|
||||||
|
/// correctness oracle for the whole splice path. Mirrors
|
||||||
|
/// `replay_built_signal_compiles_identically_to_graphbuilder` (topology
|
||||||
|
/// identity) and `blueprint_serde::serialized_blueprint_runs_bit_identical_
|
||||||
|
/// to_rust_built` (bit-identical run).
|
||||||
|
#[test]
|
||||||
|
fn use_op_splices_byte_identical_to_the_rust_built_twin() {
|
||||||
|
use crate::blueprint_serde::blueprint_to_json;
|
||||||
|
use crate::GraphBuilder;
|
||||||
|
|
||||||
|
let subgraph = |ref_id: &str| (ref_id == "fixture-id").then(use_fixture);
|
||||||
|
|
||||||
|
// (a) op-script: a bound `price` source feeds the spliced instance's
|
||||||
|
// own "price" port (`Op::Source`, not `Op::Input` — this test
|
||||||
|
// bootstraps and runs the result, and only compile/bootstrap gate
|
||||||
|
// root-role boundness now, #317; finish() itself no longer cares);
|
||||||
|
// the instance's "out" field re-exports as "bias".
|
||||||
|
let ops = vec![
|
||||||
|
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
|
||||||
|
Op::Use {
|
||||||
|
ref_id: "fixture-id".into(),
|
||||||
|
name: Some("gate".into()),
|
||||||
|
bind: vec![("sma.length".into(), Scalar::i64(5))],
|
||||||
|
},
|
||||||
|
Op::Feed { role: "price".into(), into: vec!["gate.price".into()] },
|
||||||
|
Op::Expose { from: "gate.out".into(), as_name: "bias".into() },
|
||||||
|
];
|
||||||
|
let replayed = super::replay("root", ops, &std_vocabulary, &subgraph).expect("replays");
|
||||||
|
|
||||||
|
// (b) the GraphBuilder twin — the identical splice: the SAME fixture,
|
||||||
|
// renamed to "gate", the SAME bind, added the way a Rust author would.
|
||||||
|
let mut g = GraphBuilder::new("root");
|
||||||
|
let price = g.source_role("price", ScalarKind::F64);
|
||||||
|
let spliced = use_fixture()
|
||||||
|
.renamed("gate")
|
||||||
|
.bind_path("sma.length", Scalar::i64(5))
|
||||||
|
.expect("binds");
|
||||||
|
let gate = g.add(spliced);
|
||||||
|
g.feed(price, [gate.input("price")]);
|
||||||
|
g.expose(gate.output("out"), "bias");
|
||||||
|
let built = g.build().expect("root resolves");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
blueprint_to_json(&replayed).expect("replay serializes"),
|
||||||
|
blueprint_to_json(&built).expect("builder serializes"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// bootstrap + run bit-identical (only bias.scale is open: sma.length
|
||||||
|
// was bound at the use seam).
|
||||||
|
let trace_replayed = run_recording(replayed);
|
||||||
|
let trace_built = run_recording(built);
|
||||||
|
assert_eq!(
|
||||||
|
trace_replayed, trace_built,
|
||||||
|
"the op-script splice diverged from the Rust-built twin at run time"
|
||||||
|
);
|
||||||
|
assert!(!trace_replayed.is_empty(), "trace must be populated (non-degenerate)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A miss on the injected `subgraph` resolver is a by-identifier fault
|
||||||
|
/// naming the `ref_id` (#317) — reachable directly against `GraphSession`/
|
||||||
|
/// `replay` even though the CLI (Task 3) always pre-resolves.
|
||||||
|
#[test]
|
||||||
|
fn use_op_unknown_subgraph_is_a_by_identifier_fault() {
|
||||||
|
let mut s = GraphSession::new("g", &std_vocabulary, &|_: &str| None);
|
||||||
|
assert_eq!(
|
||||||
|
s.apply(Op::Use { ref_id: "ghost".into(), name: None, bind: vec![] }),
|
||||||
|
Err(OpError::UnknownSubgraph { ref_id: "ghost".into() })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An instance identifier colliding with an already-added node is the
|
||||||
|
/// existing `DuplicateIdentifier` refusal — instance names share the
|
||||||
|
/// interior node-identifier namespace with primitives (#317).
|
||||||
|
#[test]
|
||||||
|
fn use_op_duplicate_instance_identifier_rejected() {
|
||||||
|
let subgraph = |_: &str| Some(use_fixture());
|
||||||
|
let mut s = GraphSession::new("g", &std_vocabulary, &subgraph);
|
||||||
|
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("gate".into()), bind: vec![] }).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
s.apply(Op::Use { ref_id: "fixture".into(), name: Some("gate".into()), bind: vec![] }),
|
||||||
|
Err(OpError::DuplicateIdentifier("gate".into()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A post-splice `bind` path with no matching open param anywhere in the
|
||||||
|
/// instance faults through the existing bind-fault shape (`BadParam`),
|
||||||
|
/// naming the instance and the qualified within-instance path (#317).
|
||||||
|
#[test]
|
||||||
|
fn use_op_bind_of_a_closed_path_faults_with_the_qualified_path() {
|
||||||
|
let subgraph = |_: &str| Some(use_fixture());
|
||||||
|
let mut s = GraphSession::new("g", &std_vocabulary, &subgraph);
|
||||||
|
assert_eq!(
|
||||||
|
s.apply(Op::Use {
|
||||||
|
ref_id: "fixture".into(),
|
||||||
|
name: Some("gate".into()),
|
||||||
|
bind: vec![("ghost.length".into(), Scalar::i64(2))],
|
||||||
|
}),
|
||||||
|
Err(OpError::BadParam {
|
||||||
|
node: "gate".into(),
|
||||||
|
err: BindOpError::UnknownParam("ghost.length".into()),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A gang-bearing fixture for the ganged-member-bind refusal test below
|
||||||
|
/// (review finding, #317 follow-up): two `SMA`s ganged on `length` under
|
||||||
|
/// one public `channel_length` knob, feeding a `Sub` whose difference
|
||||||
|
/// re-exports as `out`. Mirrors `gang_op_projects_the_param_space`'s
|
||||||
|
/// op-script shape; built fresh on every call like `use_fixture` (never
|
||||||
|
/// shared/cloned — `Composite` holds build closures and is not `Clone`).
|
||||||
|
fn use_gang_fixture() -> Composite {
|
||||||
|
let ops = vec![
|
||||||
|
Op::Input { role: "price".into() },
|
||||||
|
Op::Add { type_id: "SMA".into(), as_name: Some("channel_hi".into()), bind: vec![] },
|
||||||
|
Op::Add { type_id: "SMA".into(), as_name: Some("channel_lo".into()), bind: vec![] },
|
||||||
|
Op::Gang {
|
||||||
|
as_name: "channel_length".into(),
|
||||||
|
into: vec!["channel_hi.length".into(), "channel_lo.length".into()],
|
||||||
|
},
|
||||||
|
Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] },
|
||||||
|
Op::Feed {
|
||||||
|
role: "price".into(),
|
||||||
|
into: vec!["channel_hi.series".into(), "channel_lo.series".into()],
|
||||||
|
},
|
||||||
|
Op::Connect { from: "channel_hi.value".into(), to: "sub.lhs".into() },
|
||||||
|
Op::Connect { from: "channel_lo.value".into(), to: "sub.rhs".into() },
|
||||||
|
Op::Expose { from: "sub.value".into(), as_name: "out".into() },
|
||||||
|
];
|
||||||
|
super::replay("gang_fixture", ops, &std_vocabulary, &|_: &str| None).expect("gang fixture replays")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Silent gang de-fuse guard (review finding, #317 follow-up): binding a
|
||||||
|
/// GANGED member's raw path through `Op::Use`'s post-splice `bind` must
|
||||||
|
/// not quietly freeze that one member while the gang's public knob keeps
|
||||||
|
/// driving its sibling — refused by identifier, naming the full
|
||||||
|
/// qualified path and the gang's own public name. The gang's own public
|
||||||
|
/// knob (`gate.channel_length`) staying unbindable at the use seam is
|
||||||
|
/// accepted residue, not covered here.
|
||||||
|
#[test]
|
||||||
|
fn use_op_bind_of_a_ganged_member_path_refuses_by_identifier() {
|
||||||
|
let subgraph = |_: &str| Some(use_gang_fixture());
|
||||||
|
let mut s = GraphSession::new("g", &std_vocabulary, &subgraph);
|
||||||
|
assert_eq!(
|
||||||
|
s.apply(Op::Use {
|
||||||
|
ref_id: "fixture".into(),
|
||||||
|
name: Some("gate".into()),
|
||||||
|
bind: vec![("channel_hi.length".into(), Scalar::i64(20))],
|
||||||
|
}),
|
||||||
|
Err(OpError::BadParam {
|
||||||
|
node: "gate".into(),
|
||||||
|
err: BindOpError::AlreadyGanged {
|
||||||
|
param: "channel_hi.length".into(),
|
||||||
|
gang: "channel_length".into(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DAG invariant (domain invariant 5 / C9), through an instance: a
|
||||||
|
/// `connect` closing a cycle through a spliced instance is rejected
|
||||||
|
/// eagerly, the instance treated as one opaque node (#317) — the same
|
||||||
|
/// `closes_cycle` gate `replay_rejects_dataflow_cycle` pins for primitives.
|
||||||
|
#[test]
|
||||||
|
fn use_op_connect_closing_a_cycle_through_an_instance_is_rejected_eagerly() {
|
||||||
|
let subgraph = |_: &str| Some(use_fixture());
|
||||||
|
let mut s = GraphSession::new("g", &std_vocabulary, &subgraph);
|
||||||
|
s.apply(Op::Add { type_id: "Sub".into(), as_name: Some("s".into()), bind: vec![] }).unwrap();
|
||||||
|
s.apply(Op::Use { ref_id: "fixture".into(), name: Some("gate".into()), bind: vec![] }).unwrap();
|
||||||
|
s.apply(Op::Connect { from: "s.value".into(), to: "gate.price".into() }).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
s.apply(Op::Connect { from: "gate.out".into(), to: "s.lhs".into() }),
|
||||||
|
Err(OpError::WouldCycle { from: "gate.out".into(), to: "s.lhs".into() })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An unfed instance in-port finishes with the by-identifier
|
||||||
|
/// `UnconnectedPort`, naming the instance and the instance's own role name
|
||||||
|
/// (rendered `<instance>.<role>` by the CLI's `format_op_error`) — the
|
||||||
|
/// instance's open input roles ARE its in-ports (#317).
|
||||||
|
#[test]
|
||||||
|
fn use_op_unfed_instance_role_reports_the_qualified_identifier() {
|
||||||
|
let subgraph = |_: &str| Some(use_fixture());
|
||||||
|
let mut s = GraphSession::new("g", &std_vocabulary, &subgraph);
|
||||||
|
s.apply(Op::Use { ref_id: "fixture".into(), name: Some("gate".into()), bind: vec![] }).unwrap();
|
||||||
|
s.apply(Op::Expose { from: "gate.out".into(), as_name: "bias".into() }).unwrap();
|
||||||
|
// gate.price deliberately left unfed.
|
||||||
|
let err = s.finish().err().unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(&err, OpError::UnconnectedPort { node, slot } if node == "gate" && slot == "price"),
|
||||||
|
"an unfed instance role finishes by-identifier as <instance>.<role>, got {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An instance's still-open interior params surface path-qualified under
|
||||||
|
/// the instance identifier (`<instance>.<node>.<param>`, #317) — the
|
||||||
|
/// existing `collect_params` composite-arm prefix rule, now exercised
|
||||||
|
/// through `Op::Use`'s rename-on-splice (extends the
|
||||||
|
/// `param_space_is_flat_path_qualified_and_slot_disambiguated` family).
|
||||||
|
#[test]
|
||||||
|
fn use_op_instance_params_surface_path_qualified() {
|
||||||
|
let subgraph = |_: &str| Some(use_fixture());
|
||||||
|
let mut s = GraphSession::new("g", &std_vocabulary, &subgraph);
|
||||||
|
// a bound root role (mirroring the headline twin test's shape;
|
||||||
|
// `finish()` itself no longer cares either way, #317).
|
||||||
|
s.apply(Op::Source { role: "price".into(), kind: ScalarKind::F64 }).unwrap();
|
||||||
|
s.apply(Op::Use { ref_id: "fixture".into(), name: Some("gate".into()), bind: vec![] }).unwrap();
|
||||||
|
s.apply(Op::Feed { role: "price".into(), into: vec!["gate.price".into()] }).unwrap();
|
||||||
|
s.apply(Op::Expose { from: "gate.out".into(), as_name: "bias".into() }).unwrap();
|
||||||
|
let c = s.finish().expect("finishes");
|
||||||
|
let names: Vec<String> = c.param_space().into_iter().map(|p| p.name).collect();
|
||||||
|
assert_eq!(names, ["gate.sma.length", "gate.bias.scale"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open patterns end-to-end (#317, spec §"Open patterns"): a pattern
|
||||||
|
/// declaring its formal parameter as an `input` role `finish()`es even
|
||||||
|
/// though nothing ever binds it (the root-role gate no longer runs in
|
||||||
|
/// `finish`), survives a real serialize/deserialize round trip through
|
||||||
|
/// the stored JSON form (the same bytes a registry fetch would hand
|
||||||
|
/// back), and a source-rooted consumer script splices the RELOADED
|
||||||
|
/// pattern by ref id through the injected `subgraph` lookup — the outer
|
||||||
|
/// script compiles and runs, proving the whole author's round trip
|
||||||
|
/// (build open pattern -> store -> reload -> `use` -> run), not just an
|
||||||
|
/// in-memory splice.
|
||||||
|
#[test]
|
||||||
|
fn open_input_pattern_finishes_registers_shaped_and_splices() {
|
||||||
|
use crate::blueprint_serde::{blueprint_from_json, blueprint_to_json};
|
||||||
|
|
||||||
|
// (a) the reusable pattern: an open `x` input role feeds an Sma, whose
|
||||||
|
// value re-exports as `out`.
|
||||||
|
let pattern_ops = vec![
|
||||||
|
Op::Input { role: "x".into() },
|
||||||
|
Op::Add {
|
||||||
|
type_id: "SMA".into(),
|
||||||
|
as_name: Some("sma".into()),
|
||||||
|
bind: vec![("length".into(), Scalar::i64(3))],
|
||||||
|
},
|
||||||
|
Op::Feed { role: "x".into(), into: vec!["sma.series".into()] },
|
||||||
|
Op::Expose { from: "sma.value".into(), as_name: "out".into() },
|
||||||
|
];
|
||||||
|
let pattern = super::replay("pattern", pattern_ops, &std_vocabulary, &|_: &str| None)
|
||||||
|
.expect("an input-role pattern finishes without root-role boundness");
|
||||||
|
|
||||||
|
// (b) round-trip through the serialized store form.
|
||||||
|
let json = blueprint_to_json(&pattern).expect("an open pattern serializes");
|
||||||
|
|
||||||
|
// (c) a source-rooted outer script splices the RELOADED pattern by ref
|
||||||
|
// id (the injected `subgraph` lookup re-parses the stored JSON on
|
||||||
|
// every call, mirroring a fresh registry fetch — `Composite` is not
|
||||||
|
// `Clone`).
|
||||||
|
let subgraph = |ref_id: &str| {
|
||||||
|
(ref_id == "pattern-id")
|
||||||
|
.then(|| blueprint_from_json(&json, &std_vocabulary).expect("an open pattern deserializes"))
|
||||||
|
};
|
||||||
|
let outer_ops = vec![
|
||||||
|
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
|
||||||
|
Op::Use { ref_id: "pattern-id".into(), name: Some("p".into()), bind: vec![] },
|
||||||
|
Op::Feed { role: "price".into(), into: vec!["p.x".into()] },
|
||||||
|
Op::Expose { from: "p.out".into(), as_name: "bias".into() },
|
||||||
|
];
|
||||||
|
let outer = super::replay("root", outer_ops, &std_vocabulary, &subgraph)
|
||||||
|
.expect("the outer script splices the reloaded open pattern");
|
||||||
|
|
||||||
|
// (d) compiles and runs — the same recording-harness shape
|
||||||
|
// `run_recording` uses, but with zero open params (this pattern binds
|
||||||
|
// `sma.length` at the pattern seam, unlike the shared fixture, so
|
||||||
|
// `run_recording`'s hardcoded one-F64-param point does not apply).
|
||||||
|
use crate::harness::{Edge, Target};
|
||||||
|
use aura_core::Firing;
|
||||||
|
use aura_std::Recorder;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
let root = Composite::new(
|
||||||
|
"h",
|
||||||
|
vec![
|
||||||
|
BlueprintNode::Composite(outer),
|
||||||
|
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 mut h = root.bootstrap().expect("bootstraps with no open params");
|
||||||
|
h.run(vec![Box::new(crate::VecSource::new(crate::test_fixtures::synthetic_prices()))]);
|
||||||
|
let trace: Vec<_> = rx.try_iter().collect();
|
||||||
|
assert!(!trace.is_empty(), "the spliced open pattern must actually produce output");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ fn signal_ops() -> Vec<Op> {
|
|||||||
#[test]
|
#[test]
|
||||||
fn replayed_construction_compiles_identically_to_graphbuilder() {
|
fn replayed_construction_compiles_identically_to_graphbuilder() {
|
||||||
// (a) op-script replay through the public construction surface.
|
// (a) op-script replay through the public construction surface.
|
||||||
let replay_flat = replay("root", signal_ops(), &std_vocabulary)
|
let replay_flat = replay("root", signal_ops(), &std_vocabulary, &|_: &str| None)
|
||||||
.expect("op-script resolves")
|
.expect("op-script resolves")
|
||||||
.compile_with_params(¶ms())
|
.compile_with_params(¶ms())
|
||||||
.expect("replay compiles");
|
.expect("replay compiles");
|
||||||
@@ -103,7 +103,7 @@ fn replay_attributes_eager_fault_to_its_op_index() {
|
|||||||
];
|
];
|
||||||
// `.err().unwrap()` (not `unwrap_err`): the Ok arm `Composite` holds build
|
// `.err().unwrap()` (not `unwrap_err`): the Ok arm `Composite` holds build
|
||||||
// closures and is not `Debug`, which `unwrap_err`'s bound would require.
|
// closures and is not `Debug`, which `unwrap_err`'s bound would require.
|
||||||
let err = replay("g", ops, &std_vocabulary).err().unwrap();
|
let err = replay("g", ops, &std_vocabulary, &|_: &str| None).err().unwrap();
|
||||||
assert_eq!(err, (2, OpError::UnknownNodeType("Nope".into())));
|
assert_eq!(err, (2, OpError::UnknownNodeType("Nope".into())));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ fn replay_attributes_holistic_fault_to_finalize_step() {
|
|||||||
// sub.rhs deliberately left unwired -> only finalize can decide totality.
|
// sub.rhs deliberately left unwired -> only finalize can decide totality.
|
||||||
];
|
];
|
||||||
let finalize_index = ops.len(); // 6 — the implicit finalize step
|
let finalize_index = ops.len(); // 6 — the implicit finalize step
|
||||||
let err = replay("g", ops, &std_vocabulary).err().unwrap();
|
let err = replay("g", ops, &std_vocabulary, &|_: &str| None).err().unwrap();
|
||||||
assert!(matches!(&err, (i, OpError::UnconnectedPort { .. }) if *i == finalize_index),
|
assert!(matches!(&err, (i, OpError::UnconnectedPort { .. }) if *i == finalize_index),
|
||||||
"the holistic fault is still attributed to the finalize step, got {err:?}");
|
"the holistic fault is still attributed to the finalize step, got {err:?}");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user