feat(engine,cli): gang serde + identity + the gang construction op (#61 tasks 3-4)
Serde: CompositeData carries an additive-optional gangs section (omitted when empty — every existing v1 document round-trips byte-identically, NO format-version bump per the #61 decision; BLUEPRINT_FORMAT_VERSION doc now records the pre-ship Tier-2 dormancy). project() emits gangs canonically (members by (node,pos), gangs by first member) so declaration order never reaches the content id; reconstruct() re-mints through Composite::with_gangs and a corrupted section refuses as the new typed LoadError::Gang. strip_debug_symbols blanks gang + member names, keeps (node,pos,kind) — the identity id stays gang-name-blind and gang-structure-sensitive. Construction: Op::Gang { as_name, into } with the feed-style two-phase eager arm (member resolution against the post-bind schemas, kind uniformity, single-gang membership; OpError::{GangKindMismatch,AlreadyGanged,GangArity}); finish() routes the assembled composite through with_gangs, and gang-name collisions surface via the existing injectivity gate on the projected space. CLI: OpDoc::Gang ({"op":"gang","as":…,"into":[…]}), kind_label/From/ format_op_error arms, and format_load_error phrases gang faults as prose (gang_fault_prose) instead of leaking Debug — a ratified improvement over the plan bytes. Held quality nit (principled plan-hold): gang()'s inline collect-then-reject param resolution is the module's established convention (bind/try_bind); a shared cross-crate helper is out of this task's scope. Verified: workspace build clean, aura-engine lib 258/0, aura-cli graph_construct 48/0 (incl. the new gang-op e2e), clippy -D warnings clean. refs #61
This commit is contained in:
@@ -9,7 +9,7 @@ use std::path::Path;
|
||||
|
||||
use aura_engine::{
|
||||
blueprint_from_json, blueprint_identity_json, blueprint_to_json, replay, BindOpError,
|
||||
Composite, GraphSession, LoadError, Op, OpError, Scalar, ScalarKind,
|
||||
CompileError, Composite, GraphSession, LoadError, Op, OpError, Scalar, ScalarKind,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
@@ -44,6 +44,11 @@ enum OpDoc {
|
||||
#[serde(rename = "as")]
|
||||
as_name: String,
|
||||
},
|
||||
Gang {
|
||||
#[serde(rename = "as")]
|
||||
as_name: String,
|
||||
into: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl OpDoc {
|
||||
@@ -56,6 +61,7 @@ impl OpDoc {
|
||||
OpDoc::Feed { .. } => "feed",
|
||||
OpDoc::Connect { .. } => "connect",
|
||||
OpDoc::Expose { .. } => "expose",
|
||||
OpDoc::Gang { .. } => "gang",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,6 +77,7 @@ impl From<OpDoc> for Op {
|
||||
OpDoc::Feed { role, into } => Op::Feed { role, into },
|
||||
OpDoc::Connect { from, to } => Op::Connect { from, to },
|
||||
OpDoc::Expose { from, as_name } => Op::Expose { from, as_name },
|
||||
OpDoc::Gang { as_name, into } => Op::Gang { as_name, into },
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,6 +113,13 @@ fn format_op_error(e: &OpError) -> String {
|
||||
OpError::UnconnectedPort { node, slot } => format!("slot {node}.{slot} is unconnected"),
|
||||
OpError::RoleKindMismatch { role } => format!("input role {role} fans into slots of differing kinds"),
|
||||
OpError::UnboundRootRole { role } => format!("root input role {role} is unbound"),
|
||||
OpError::GangKindMismatch { member, expected, got } => {
|
||||
format!("gang: member `{member}` is {got:?}, expected {expected:?}")
|
||||
}
|
||||
OpError::AlreadyGanged { node, param } => {
|
||||
format!("gang: `{node}.{param}` is already ganged")
|
||||
}
|
||||
OpError::GangArity { gang } => format!("gang `{gang}`: needs at least two members"),
|
||||
OpError::Incomplete(ce) => format!("{ce:?}"),
|
||||
}
|
||||
}
|
||||
@@ -325,6 +339,40 @@ pub(crate) fn blueprint_load_prose(e: &LoadError) -> String {
|
||||
format!("blueprint format_version {found} is unsupported (this build reads {supported})")
|
||||
}
|
||||
LoadError::UnknownNodeType(t) => format!("unknown node type {t:?}"),
|
||||
LoadError::Gang(e) => format!("gang section invalid: {}", gang_fault_prose(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Phrase a gang-table `CompileError` as prose (Debug-leak-free, mirroring
|
||||
/// `blueprint_load_prose`'s convention). `with_gangs` — the only producer of
|
||||
/// `LoadError::Gang` — always yields `CompileError::BadGang`; a bare fallback
|
||||
/// covers the type's other variants without ever printing a raw `{e:?}`.
|
||||
fn gang_fault_prose(e: &CompileError) -> String {
|
||||
use aura_engine::GangFault as F;
|
||||
let CompileError::BadGang(fault) = e else {
|
||||
return "malformed gang table".to_string();
|
||||
};
|
||||
match fault {
|
||||
F::BadName { gang } => format!("gang {gang:?} has an empty or dotted name"),
|
||||
F::TooFewMembers { gang } => format!("gang {gang:?} has fewer than two members"),
|
||||
F::NodeOutOfRange { gang, node } => {
|
||||
format!("gang {gang:?} references out-of-range node {node}")
|
||||
}
|
||||
F::NotAPrimitive { gang, node } => {
|
||||
format!("gang {gang:?} member node {node} is not a primitive")
|
||||
}
|
||||
F::NoOpenParam { gang, node, name } => {
|
||||
format!("gang {gang:?} member node {node} has no open param {name:?}")
|
||||
}
|
||||
F::PosMismatch { gang, node, expected, got } => format!(
|
||||
"gang {gang:?} member node {node} declares position {got} but the param sits at {expected}"
|
||||
),
|
||||
F::KindMismatch { gang, node, name, expected, got } => format!(
|
||||
"gang {gang:?} member node {node} param {name:?} is {got:?} but the gang expects {expected:?}"
|
||||
),
|
||||
F::MemberInTwoGangs { gang, node, pos } => format!(
|
||||
"gang {gang:?} member (node {node}, pos {pos}) already belongs to another gang"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"format_version":1,"blueprint":{"name":"sig","nodes":[{"primitive":{"type":"SMA","name":"fast"}}],"gangs":[{"name":"solo","kind":"I64","members":[{"node":0,"pos":0,"name":"length"}]}]}}
|
||||
@@ -781,3 +781,175 @@ fn shipped_r_meanrev_open_example_lists_its_axis_namespace() {
|
||||
"the raw open params, in lowering order"
|
||||
);
|
||||
}
|
||||
|
||||
/// The op-script surface accepts a gang op and the built blueprint exposes
|
||||
/// ONE public knob for the fused pair (#61).
|
||||
#[test]
|
||||
fn graph_build_accepts_a_gang_op() {
|
||||
let dir = temp_cwd("graph-build-gang-op");
|
||||
let ops = r#"[
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"add","type":"SMA","name":"a"},
|
||||
{"op":"add","type":"SMA","name":"b"},
|
||||
{"op":"gang","as":"length","into":["a.length","b.length"]},
|
||||
{"op":"add","type":"Sub"},
|
||||
{"op":"feed","role":"price","into":["a.series","b.series"]},
|
||||
{"op":"connect","from":"a.value","to":"sub.lhs"},
|
||||
{"op":"connect","from":"b.value","to":"sub.rhs"},
|
||||
{"op":"expose","from":"sub.value","as":"bias"}
|
||||
]"#;
|
||||
let (bp, stderr, ok) = run(&["graph", "build"], ops);
|
||||
assert!(ok, "graph build: stderr: {stderr}");
|
||||
// Write the built blueprint to a file, then introspect its raw param space
|
||||
// (mirrors `graph_content_id_accepts_an_op_list_file`'s file-write plumbing).
|
||||
let bp_path = dir.join("bp.json");
|
||||
std::fs::write(&bp_path, &bp).expect("write built blueprint");
|
||||
let (params, stderr2, code2) =
|
||||
run_in(&dir, &["graph", "introspect", "--params", bp_path.to_str().unwrap()]);
|
||||
assert_eq!(code2, Some(0), "stderr: {stderr2}");
|
||||
assert_eq!(params, "length:I64\n", "one fused public knob, not two members");
|
||||
}
|
||||
|
||||
/// The `gang` op's arity refusal (fewer than two members) reads as prose at the
|
||||
/// binary seam, attributed to its op index, never the raw `GangArity` Debug name.
|
||||
#[test]
|
||||
fn graph_build_reports_gang_arity_fault_as_prose() {
|
||||
let doc = r#"[
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"add","type":"SMA","name":"a"},
|
||||
{"op":"gang","as":"solo","into":["a.length"]}
|
||||
]"#;
|
||||
let (stdout, stderr, ok) = run(&["graph", "build"], doc);
|
||||
assert!(!ok, "non-zero exit on a gang arity fault");
|
||||
assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
|
||||
assert!(
|
||||
stderr.contains("op 2 (gang): gang `solo`: needs at least two members"),
|
||||
"names the op + cause as prose: {stderr}"
|
||||
);
|
||||
assert!(!stderr.contains("GangArity"), "does not leak the Debug variant name: {stderr}");
|
||||
}
|
||||
|
||||
/// The `gang` op's double-membership refusal (a param already claimed by an
|
||||
/// earlier gang) reads as prose, naming the offending `node.param`.
|
||||
#[test]
|
||||
fn graph_build_reports_already_ganged_fault_as_prose() {
|
||||
let doc = r#"[
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"add","type":"SMA","name":"a"},
|
||||
{"op":"add","type":"SMA","name":"b"},
|
||||
{"op":"add","type":"SMA","name":"c"},
|
||||
{"op":"gang","as":"lo","into":["a.length","b.length"]},
|
||||
{"op":"gang","as":"hi","into":["a.length","c.length"]}
|
||||
]"#;
|
||||
let (stdout, stderr, ok) = run(&["graph", "build"], doc);
|
||||
assert!(!ok, "non-zero exit on an already-ganged fault");
|
||||
assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
|
||||
assert!(
|
||||
stderr.contains("op 5 (gang): gang: `a.length` is already ganged"),
|
||||
"names the op + doubly-ganged member as prose: {stderr}"
|
||||
);
|
||||
assert!(!stderr.contains("AlreadyGanged"), "does not leak the Debug variant name: {stderr}");
|
||||
}
|
||||
|
||||
/// The `gang` op's kind-mismatch refusal (members of differing scalar kind)
|
||||
/// reads as prose, naming the offending member and both kinds.
|
||||
#[test]
|
||||
fn graph_build_reports_gang_kind_mismatch_as_prose() {
|
||||
let doc = r#"[
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"add","type":"SMA","name":"a"},
|
||||
{"op":"add","type":"Bias","name":"b"},
|
||||
{"op":"gang","as":"g","into":["a.length","b.scale"]}
|
||||
]"#;
|
||||
let (stdout, stderr, ok) = run(&["graph", "build"], doc);
|
||||
assert!(!ok, "non-zero exit on a gang kind mismatch");
|
||||
assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
|
||||
assert!(
|
||||
stderr.contains("op 3 (gang): gang: member `b.scale` is F64, expected I64"),
|
||||
"names the op + kind mismatch as prose: {stderr}"
|
||||
);
|
||||
assert!(!stderr.contains("GangKindMismatch"), "does not leak the Debug variant name: {stderr}");
|
||||
}
|
||||
|
||||
/// Property (corrupt-gang-blueprint, the `gang_fault_prose` seam): `aura graph
|
||||
/// register` on a hand-corrupted blueprint whose gangs section fails structural
|
||||
/// validation (a single-member gang) refuses with the `blueprint_load_prose` ->
|
||||
/// `gang_fault_prose` wording — never the raw `CompileError`/`GangFault` Debug
|
||||
/// dump the pre-#61 fallback would otherwise leak. Mirrors
|
||||
/// `graph_register_rejects_an_unknown_node_type_with_prose`'s pattern, one layer
|
||||
/// further into the load path.
|
||||
#[test]
|
||||
fn graph_register_rejects_a_corrupt_gang_table_with_prose() {
|
||||
let dir = temp_cwd("register-bad-gang");
|
||||
let (stdout, stderr, code) = run_in(&dir, &["graph", "register", &fixture("bad_gang.json")]);
|
||||
assert_ne!(code, Some(0), "non-zero exit: {stdout} {stderr}");
|
||||
assert!(stdout.is_empty(), "no registration line on refusal: {stdout}");
|
||||
assert!(
|
||||
stderr.contains("gang section invalid: gang \"solo\" has fewer than two members"),
|
||||
"phrases the corrupt gang table as prose: {stderr}"
|
||||
);
|
||||
assert!(!stderr.contains("GangFault"), "does not leak the Debug type name: {stderr}");
|
||||
assert!(!stderr.contains("BadGang"), "does not leak the Debug variant name: {stderr}");
|
||||
}
|
||||
|
||||
/// Property (#61, the `blueprint_serde` canonicalization the CLI content-id
|
||||
/// path relies on): declaring a gang's members in a different order in the
|
||||
/// op-script's `into` list must NOT change the built blueprint's content id.
|
||||
/// `project()`'s member/gang sort (task 3) exists precisely so that the
|
||||
/// write-once content-addressed store (`graph register`'s idempotency
|
||||
/// contract) sees two authoring-order variants of the identical graph as one
|
||||
/// blueprint; were the sort ever dropped, this is the CLI-boundary regression
|
||||
/// that would silently start minting a second id for the same topology.
|
||||
#[test]
|
||||
fn graph_build_gang_member_order_does_not_affect_content_id() {
|
||||
let forward = r#"[
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"add","type":"SMA","name":"a"},
|
||||
{"op":"add","type":"SMA","name":"b"},
|
||||
{"op":"gang","as":"length","into":["a.length","b.length"]},
|
||||
{"op":"add","type":"Sub"},
|
||||
{"op":"feed","role":"price","into":["a.series","b.series"]},
|
||||
{"op":"connect","from":"a.value","to":"sub.lhs"},
|
||||
{"op":"connect","from":"b.value","to":"sub.rhs"},
|
||||
{"op":"expose","from":"sub.value","as":"bias"}
|
||||
]"#;
|
||||
let reversed = r#"[
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"add","type":"SMA","name":"a"},
|
||||
{"op":"add","type":"SMA","name":"b"},
|
||||
{"op":"gang","as":"length","into":["b.length","a.length"]},
|
||||
{"op":"add","type":"Sub"},
|
||||
{"op":"feed","role":"price","into":["a.series","b.series"]},
|
||||
{"op":"connect","from":"a.value","to":"sub.lhs"},
|
||||
{"op":"connect","from":"b.value","to":"sub.rhs"},
|
||||
{"op":"expose","from":"sub.value","as":"bias"}
|
||||
]"#;
|
||||
let (id_forward, e1, ok1) = run(&["graph", "introspect", "--content-id"], forward);
|
||||
let (id_reversed, e2, ok2) = run(&["graph", "introspect", "--content-id"], reversed);
|
||||
assert!(ok1, "forward: {e1}");
|
||||
assert!(ok2, "reversed: {e2}");
|
||||
assert_eq!(id_forward, id_reversed, "gang member declaration order is content-id-invisible");
|
||||
}
|
||||
|
||||
/// Property (#61, the eager gang gate's bind-then-gang ordering): a member
|
||||
/// already bound at `add` has left the node's open param schema exactly like
|
||||
/// any other consumed param, so ganging it reports the generic `UnknownParam`
|
||||
/// prose — not a gang-specific "already bound" message — proving the gang op
|
||||
/// resolves each member against the CURRENT open schema (mirroring `try_bind`)
|
||||
/// rather than against the node's original, pre-bind schema.
|
||||
#[test]
|
||||
fn graph_build_gang_on_a_bound_member_reports_unknown_param() {
|
||||
let doc = r#"[
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"add","type":"SMA","name":"a","bind":{"length":{"I64":5}}},
|
||||
{"op":"add","type":"SMA","name":"b"},
|
||||
{"op":"gang","as":"length","into":["a.length","b.length"]}
|
||||
]"#;
|
||||
let (stdout, stderr, ok) = run(&["graph", "build"], doc);
|
||||
assert!(!ok, "non-zero exit: a bound member cannot be ganged");
|
||||
assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
|
||||
assert!(
|
||||
stderr.contains("op 3 (gang): node a has no param \"length\""),
|
||||
"the bound member reads as an ordinary unknown param, not a gang-specific fault: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,12 +8,16 @@
|
||||
//! resolver. This is the inverse of the lossy render half (`model_to_json`); it
|
||||
//! carries no node logic (C17) and references a closed vocabulary (C24).
|
||||
|
||||
use crate::blueprint::{BlueprintNode, Composite, OutField, Role};
|
||||
use crate::blueprint::{BlueprintNode, Composite, Gang, OutField, Role};
|
||||
use crate::harness::Edge;
|
||||
use aura_core::{BoundParam, PrimitiveBuilder};
|
||||
|
||||
/// The format version the loader understands. Bumped only by a load-bearing
|
||||
/// (Tier-2) change; additive optional fields do not bump it (#156).
|
||||
/// (Tier-2) change; additive optional fields do not bump it (#156). Pre-ship
|
||||
/// dormancy (#61, 2026-07-10): while the project is unshipped every document
|
||||
/// lives in-repo and reader/writer change atomically, so the Tier-2 bump
|
||||
/// discipline activates at the first external ship — which consciously
|
||||
/// freezes v1 (gangs included).
|
||||
pub const BLUEPRINT_FORMAT_VERSION: u32 = 1;
|
||||
|
||||
/// Top-level envelope: the version is read before the payload is interpreted.
|
||||
@@ -35,6 +39,8 @@ pub struct CompositeData {
|
||||
pub input_roles: Vec<Role>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub output: Vec<OutField>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub gangs: Vec<Gang>,
|
||||
}
|
||||
|
||||
/// A blueprint item: a primitive (referenced by type identity) or a nested
|
||||
@@ -65,12 +71,23 @@ pub enum SerializeError {
|
||||
}
|
||||
|
||||
fn project(c: &Composite) -> CompositeData {
|
||||
// Canonical gang order (bind-order-independent, mirroring `project_node`'s
|
||||
// bound-param canonicalization below): each gang's members ascending
|
||||
// `(node, pos)`, then the gangs themselves ordered by their (now-sorted)
|
||||
// first member.
|
||||
let mut gangs = c.gangs().to_vec();
|
||||
for g in &mut gangs {
|
||||
g.members.sort_by_key(|m| (m.node, m.pos));
|
||||
}
|
||||
gangs.sort_by_key(|g| (g.members[0].node, g.members[0].pos));
|
||||
|
||||
CompositeData {
|
||||
name: c.name().to_string(),
|
||||
nodes: c.nodes().iter().map(project_node).collect(),
|
||||
edges: c.edges().to_vec(),
|
||||
input_roles: c.input_roles().to_vec(),
|
||||
output: c.output().to_vec(),
|
||||
gangs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +159,12 @@ fn strip_debug_symbols(b: &mut CompositeData) {
|
||||
for out in &mut b.output {
|
||||
out.name = String::new(); // (node, field) + order survive
|
||||
}
|
||||
for gang in &mut b.gangs {
|
||||
gang.name = String::new();
|
||||
for m in &mut gang.members {
|
||||
m.name = String::new(); // node/pos/kind survive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Loader failure (typed, named — never a panic, never a silent wrong graph).
|
||||
@@ -164,6 +187,9 @@ pub enum LoadError {
|
||||
/// `resolve` returned `None` for a serialized `type_id` (outside the injected
|
||||
/// vocabulary — unknown node, or a construction-arg/sink node not in #155's set).
|
||||
UnknownNodeType(String),
|
||||
/// The document's gangs section fails structural validation against its
|
||||
/// own nodes (a hand-edited or corrupted document).
|
||||
Gang(crate::blueprint::CompileError),
|
||||
}
|
||||
|
||||
fn reconstruct(
|
||||
@@ -192,13 +218,9 @@ fn reconstruct(
|
||||
NodeData::Composite(c) => BlueprintNode::Composite(reconstruct(c, resolve)?),
|
||||
});
|
||||
}
|
||||
Ok(Composite::new(
|
||||
d.name.clone(),
|
||||
nodes,
|
||||
d.edges.clone(),
|
||||
d.input_roles.clone(),
|
||||
d.output.clone(),
|
||||
))
|
||||
Composite::new(d.name.clone(), nodes, d.edges.clone(), d.input_roles.clone(), d.output.clone())
|
||||
.with_gangs(d.gangs.clone())
|
||||
.map_err(LoadError::Gang)
|
||||
}
|
||||
|
||||
/// Load a blueprint from canonical JSON, resolving each primitive's type identity
|
||||
@@ -221,7 +243,7 @@ pub fn blueprint_from_json(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::blueprint::{Composite, OutField, Role};
|
||||
use crate::blueprint::{Composite, GangMember, OutField, Role};
|
||||
use crate::harness::{Edge, Target};
|
||||
use crate::VecSource; // crate-root re-export (blueprint.rs tests import it the same way, e.g. :923)
|
||||
use aura_core::{Scalar, ScalarKind, Timestamp};
|
||||
@@ -542,4 +564,163 @@ mod tests {
|
||||
"identity form is role-name- and output-name-blind"
|
||||
);
|
||||
}
|
||||
|
||||
// The resolver used by the gang-serde tests below (mirrors the inline
|
||||
// `&|t| aura_std::std_vocabulary(t)` closure the rest of this module uses).
|
||||
fn fixture_resolver() -> impl Fn(&str) -> Option<PrimitiveBuilder> {
|
||||
|t: &str| aura_std::std_vocabulary(t)
|
||||
}
|
||||
|
||||
// Shared topology: two open SMAs feeding a `Sub`. `ganged_fixture_named`
|
||||
// and `unganged_twin` both build on this single definition so the gang
|
||||
// and gang-free fixtures cannot drift apart independently.
|
||||
fn two_sma_sub() -> Composite {
|
||||
Composite::new(
|
||||
"sig",
|
||||
vec![Sma::builder().named("a").into(), Sma::builder().named("b").into(), Sub::builder().into()],
|
||||
vec![
|
||||
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
||||
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
|
||||
],
|
||||
vec![Role {
|
||||
name: "price".into(),
|
||||
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
|
||||
source: Some(ScalarKind::F64),
|
||||
}],
|
||||
vec![OutField { node: 2, field: 0, name: "out".into() }],
|
||||
)
|
||||
}
|
||||
|
||||
// `two_sma_sub`, one gang fusing the two SMAs' lengths — Task 2's
|
||||
// `ganged_pair` shape, reproduced here for the serde-layer tests.
|
||||
// `gang_name` is the ONLY thing that varies across callers.
|
||||
fn ganged_fixture_named(gang_name: &str) -> Composite {
|
||||
two_sma_sub()
|
||||
.with_gangs(vec![Gang {
|
||||
name: gang_name.into(),
|
||||
kind: ScalarKind::I64,
|
||||
members: vec![
|
||||
GangMember { node: 1, pos: 0, name: "length".into() }, // declared backwards
|
||||
GangMember { node: 0, pos: 0, name: "length".into() },
|
||||
],
|
||||
}])
|
||||
.expect("two open siblings gang")
|
||||
}
|
||||
|
||||
fn ganged_fixture() -> Composite {
|
||||
ganged_fixture_named("length")
|
||||
}
|
||||
|
||||
// The un-ganged twin of `ganged_fixture`: identical topology, no gang table.
|
||||
fn unganged_twin() -> Composite {
|
||||
two_sma_sub()
|
||||
}
|
||||
|
||||
// Four open SMAs, two gangs, BOTH declared out of canonical order: the
|
||||
// gangs vec itself is swapped (the gang whose sorted first member is
|
||||
// (0,0) comes SECOND) and each gang's own members are swapped too — probes
|
||||
// the round-trip's re-canonicalization independently from `with_gangs`'s
|
||||
// own (order-blind) structural gate.
|
||||
fn ganged_fixture_declared_backwards() -> Composite {
|
||||
Composite::new(
|
||||
"sig",
|
||||
vec![
|
||||
Sma::builder().named("a").into(),
|
||||
Sma::builder().named("b").into(),
|
||||
Sma::builder().named("c").into(),
|
||||
Sma::builder().named("d").into(),
|
||||
],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
)
|
||||
.with_gangs(vec![
|
||||
Gang {
|
||||
name: "hi".into(),
|
||||
kind: ScalarKind::I64,
|
||||
members: vec![
|
||||
GangMember { node: 3, pos: 0, name: "length".into() },
|
||||
GangMember { node: 2, pos: 0, name: "length".into() },
|
||||
],
|
||||
},
|
||||
Gang {
|
||||
name: "lo".into(),
|
||||
kind: ScalarKind::I64,
|
||||
members: vec![
|
||||
GangMember { node: 1, pos: 0, name: "length".into() },
|
||||
GangMember { node: 0, pos: 0, name: "length".into() },
|
||||
],
|
||||
},
|
||||
])
|
||||
.expect("two structurally-valid gangs, declared backwards")
|
||||
}
|
||||
|
||||
// The canonical form `ganged_fixture_declared_backwards` must settle into:
|
||||
// members ascending (node,pos) within each gang, gangs ordered by their
|
||||
// (sorted) first member — "lo" (first member (0,0)) precedes "hi" ((2,0)).
|
||||
fn c_sorted_expectation() -> Vec<Gang> {
|
||||
vec![
|
||||
Gang {
|
||||
name: "lo".into(),
|
||||
kind: ScalarKind::I64,
|
||||
members: vec![
|
||||
GangMember { node: 0, pos: 0, name: "length".into() },
|
||||
GangMember { node: 1, pos: 0, name: "length".into() },
|
||||
],
|
||||
},
|
||||
Gang {
|
||||
name: "hi".into(),
|
||||
kind: ScalarKind::I64,
|
||||
members: vec![
|
||||
GangMember { node: 2, pos: 0, name: "length".into() },
|
||||
GangMember { node: 3, pos: 0, name: "length".into() },
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Gangs round-trip through the canonical form, in canonical order
|
||||
/// (members ascending (node,pos); gangs by first member) regardless of
|
||||
/// declaration order.
|
||||
#[test]
|
||||
fn gangs_round_trip_canonically() {
|
||||
let c = ganged_fixture_declared_backwards(); // members/gangs deliberately unsorted
|
||||
let json = blueprint_to_json(&c).expect("serialize");
|
||||
let back = blueprint_from_json(&json, &fixture_resolver()).expect("load");
|
||||
assert_eq!(blueprint_to_json(&back).expect("re-serialize"), json, "canonical fixpoint");
|
||||
assert_eq!(back.gangs().to_vec(), c_sorted_expectation(), "sorted members/gangs");
|
||||
assert_eq!(back.param_space().len(), c.param_space().len());
|
||||
}
|
||||
|
||||
/// A hand-corrupted gangs section (node out of range) is refused as a
|
||||
/// typed load fault, never a silent wrong graph.
|
||||
#[test]
|
||||
fn corrupt_gang_section_is_refused_on_load() {
|
||||
let mut doc: serde_json::Value =
|
||||
serde_json::from_str(&blueprint_to_json(&ganged_fixture()).unwrap()).unwrap();
|
||||
doc["blueprint"]["gangs"][0]["members"][0]["node"] = 99.into();
|
||||
// `.err().unwrap()` (not `.unwrap_err()`): the Ok type `Composite` holds a
|
||||
// build closure and is not `Debug`.
|
||||
let err = blueprint_from_json(&doc.to_string(), &fixture_resolver()).err().unwrap();
|
||||
assert!(matches!(err, LoadError::Gang(_)), "{err:?}");
|
||||
}
|
||||
|
||||
/// Identity is gang-name-blind and gang-structure-sensitive.
|
||||
#[test]
|
||||
fn gang_identity_blanks_names_keeps_structure() {
|
||||
let a = ganged_fixture_named("length");
|
||||
let b = ganged_fixture_named("window"); // same structure, different gang name
|
||||
assert_ne!(blueprint_to_json(&a).unwrap(), blueprint_to_json(&b).unwrap());
|
||||
assert_eq!(
|
||||
blueprint_identity_json(&a).unwrap(),
|
||||
blueprint_identity_json(&b).unwrap(),
|
||||
"gang names are debug symbols"
|
||||
);
|
||||
let unganged = unganged_twin();
|
||||
assert_ne!(
|
||||
blueprint_identity_json(&a).unwrap(),
|
||||
blueprint_identity_json(&unganged).unwrap(),
|
||||
"the gang itself is identity-bearing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ use aura_core::{BindOpError, NodeSchema, PrimitiveBuilder, Scalar, ScalarKind};
|
||||
|
||||
use crate::blueprint::{
|
||||
check_param_namespace_injective, check_root_roles_bound, edge_kind_check, validate_wiring,
|
||||
BlueprintNode, Composite, OutField, Role,
|
||||
BlueprintNode, Composite, Gang, GangMember, OutField, Role,
|
||||
};
|
||||
use crate::builder::{resolve_input_slot, resolve_output_field, PortResolveError};
|
||||
use crate::harness::{Edge, Target};
|
||||
@@ -37,6 +37,8 @@ pub enum Op {
|
||||
Connect { from: String, to: String },
|
||||
/// Re-export `"node.field"` at the boundary under `as_name`.
|
||||
Expose { from: String, as_name: String },
|
||||
/// Fuse two or more sibling params into one public knob (#61).
|
||||
Gang { as_name: String, into: Vec<String> },
|
||||
}
|
||||
|
||||
/// A per-op construction fault, by-identifier so the cause names the op.
|
||||
@@ -73,6 +75,12 @@ pub enum OpError {
|
||||
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.
|
||||
GangKindMismatch { member: String, expected: ScalarKind, got: ScalarKind },
|
||||
/// The param is already claimed by an earlier gang.
|
||||
AlreadyGanged { node: String, param: String },
|
||||
/// A gang needs at least two members.
|
||||
GangArity { gang: String },
|
||||
/// A holistic finalize fault (totality / injectivity / unbound root role),
|
||||
/// wrapping the unchanged engine gate's `CompileError`.
|
||||
Incomplete(CompileError),
|
||||
@@ -94,6 +102,7 @@ pub struct GraphSession<'v> {
|
||||
out: Vec<OutField>,
|
||||
out_names: HashSet<String>,
|
||||
coverage: HashMap<(usize, usize), usize>,
|
||||
gangs: Vec<Gang>,
|
||||
}
|
||||
|
||||
impl<'v> GraphSession<'v> {
|
||||
@@ -113,6 +122,7 @@ impl<'v> GraphSession<'v> {
|
||||
out: Vec::new(),
|
||||
out_names: HashSet::new(),
|
||||
coverage: HashMap::new(),
|
||||
gangs: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +136,7 @@ impl<'v> GraphSession<'v> {
|
||||
Op::Feed { role, into } => self.feed(role, into),
|
||||
Op::Connect { from, to } => self.connect(from, to),
|
||||
Op::Expose { from, as_name } => self.expose(from, as_name),
|
||||
Op::Gang { as_name, into } => self.gang(as_name, into),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,6 +304,67 @@ impl<'v> GraphSession<'v> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Eager arm of the gang gate: resolve every member against the CURRENT
|
||||
/// (post-bind) open param lists — a member bound at `Add` has left the open
|
||||
/// schema and correctly fails as `UnknownParam`, mirroring `try_bind` — enforce
|
||||
/// kind uniformity and single-gang membership, then commit. Structural validity
|
||||
/// is re-run holistically at `finish` through `Composite::with_gangs` (one
|
||||
/// predicate, two cadences, like `connect`'s eager `edge_kind_check` + the
|
||||
/// holistic `validate_wiring`).
|
||||
fn gang(&mut self, as_name: String, into: Vec<String>) -> Result<(), OpError> {
|
||||
if into.len() < 2 {
|
||||
return Err(OpError::GangArity { gang: as_name });
|
||||
}
|
||||
let mut members: Vec<GangMember> = Vec::with_capacity(into.len());
|
||||
let mut kind: Option<ScalarKind> = None;
|
||||
for port in &into {
|
||||
let (node_name, param_name) = Self::split_port(port)?;
|
||||
let ti = self.node_index(&node_name)?;
|
||||
let BlueprintNode::Primitive(b) = &self.nodes[ti] else {
|
||||
unreachable!("sessions only ever add primitives")
|
||||
};
|
||||
let hits: Vec<usize> = b
|
||||
.params()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| p.name == param_name)
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
let idx = match hits.as_slice() {
|
||||
[i] => *i,
|
||||
[] => {
|
||||
return Err(OpError::BadParam {
|
||||
node: node_name,
|
||||
err: BindOpError::UnknownParam(param_name),
|
||||
})
|
||||
}
|
||||
_ => {
|
||||
return Err(OpError::BadParam {
|
||||
node: node_name,
|
||||
err: BindOpError::AmbiguousParam(param_name),
|
||||
})
|
||||
}
|
||||
};
|
||||
let member_kind = b.params()[idx].kind;
|
||||
match kind {
|
||||
None => kind = Some(member_kind),
|
||||
Some(expected) if expected != member_kind => {
|
||||
return Err(OpError::GangKindMismatch { member: port.clone(), expected, got: member_kind })
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let pos = b.original_pos(idx);
|
||||
let already_claimed = self.gangs.iter().flat_map(|g| g.members.iter()).any(|m| m.node == ti && m.pos == pos)
|
||||
|| members.iter().any(|m| m.node == ti && m.pos == pos);
|
||||
if already_claimed {
|
||||
return Err(OpError::AlreadyGanged { node: node_name, param: param_name });
|
||||
}
|
||||
members.push(GangMember { node: ti, pos, name: param_name });
|
||||
}
|
||||
self.gangs.push(Gang { name: as_name, kind: kind.expect(">=2 members checked above"), members });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The still-unwired interior input slots (build-free introspection): every
|
||||
/// added node's declared input slots minus the covered ones, by-identifier.
|
||||
pub fn unwired(&self) -> Vec<(String, ScalarKind)> {
|
||||
@@ -320,7 +392,9 @@ impl<'v> GraphSession<'v> {
|
||||
.into_iter()
|
||||
.map(|(name, source, targets)| Role { name, targets, source })
|
||||
.collect();
|
||||
let c = Composite::new(self.name, self.nodes, self.edges, roles, self.out);
|
||||
let c = Composite::new(self.name, self.nodes, self.edges, roles, self.out)
|
||||
.with_gangs(self.gangs)
|
||||
.map_err(OpError::Incomplete)?;
|
||||
check_param_namespace_injective(&c.param_space()).map_err(OpError::Incomplete)?;
|
||||
validate_wiring(c.nodes(), c.edges(), c.input_roles(), c.output()).map_err(|e| match e {
|
||||
CompileError::UnconnectedPort { node, slot } => OpError::UnconnectedPort {
|
||||
@@ -562,6 +636,84 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The gang op fuses two sibling params into one public knob; the replayed
|
||||
/// composite's `param_space()` carries exactly the gang address.
|
||||
#[test]
|
||||
fn gang_op_projects_the_param_space() {
|
||||
let ops = vec![
|
||||
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), bind: vec![] },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("b".into()), bind: vec![] },
|
||||
Op::Gang { as_name: "length".into(), into: vec!["a.length".into(), "b.length".into()] },
|
||||
Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] },
|
||||
Op::Feed { role: "price".into(), into: vec!["a.series".into(), "b.series".into()] },
|
||||
Op::Connect { from: "a.value".into(), to: "sub.lhs".into() },
|
||||
Op::Connect { from: "b.value".into(), to: "sub.rhs".into() },
|
||||
Op::Expose { from: "sub.value".into(), as_name: "bias".into() },
|
||||
];
|
||||
let c = super::replay("sig", ops, &std_vocabulary).expect("replays");
|
||||
let names: Vec<String> = c.param_space().into_iter().map(|p| p.name).collect();
|
||||
assert_eq!(names, ["length"]);
|
||||
}
|
||||
|
||||
/// Eager refusals: unknown member param (a member bound at `Add` has left the
|
||||
/// open schema), kind mismatch across members, a doubly-ganged member, a
|
||||
/// single-member gang, and an unknown node identifier.
|
||||
#[test]
|
||||
fn gang_op_eager_refusals() {
|
||||
// bound member: `b.length` was bound at Add, so it left the open param
|
||||
// list and is unknown to the gang gate, exactly as `try_bind` would see it.
|
||||
let mut s = session("g");
|
||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), bind: vec![] }).unwrap();
|
||||
s.apply(Op::Add {
|
||||
type_id: "SMA".into(),
|
||||
as_name: Some("b".into()),
|
||||
bind: vec![("length".into(), Scalar::i64(5))],
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.apply(Op::Gang { as_name: "length".into(), into: vec!["a.length".into(), "b.length".into()] }),
|
||||
Err(OpError::BadParam { node: "b".into(), err: BindOpError::UnknownParam("length".into()) })
|
||||
);
|
||||
|
||||
// kind mismatch: SMA.length is i64, Bias.scale is f64.
|
||||
let mut s = session("g");
|
||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), bind: vec![] }).unwrap();
|
||||
s.apply(Op::Add { type_id: "Bias".into(), as_name: Some("b".into()), bind: vec![] }).unwrap();
|
||||
let r = s.apply(Op::Gang { as_name: "g".into(), into: vec!["a.length".into(), "b.scale".into()] });
|
||||
assert!(
|
||||
matches!(r, Err(OpError::GangKindMismatch { expected: ScalarKind::I64, got: ScalarKind::F64, .. })),
|
||||
"{r:?}"
|
||||
);
|
||||
|
||||
// doubly-ganged member: a.length is already claimed by an earlier gang.
|
||||
let mut s = session("g");
|
||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), bind: vec![] }).unwrap();
|
||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("b".into()), bind: vec![] }).unwrap();
|
||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("c".into()), bind: vec![] }).unwrap();
|
||||
s.apply(Op::Gang { as_name: "lo".into(), into: vec!["a.length".into(), "b.length".into()] }).unwrap();
|
||||
assert_eq!(
|
||||
s.apply(Op::Gang { as_name: "hi".into(), into: vec!["a.length".into(), "c.length".into()] }),
|
||||
Err(OpError::AlreadyGanged { node: "a".into(), param: "length".into() })
|
||||
);
|
||||
|
||||
// single member: a gang needs at least two.
|
||||
let mut s = session("g");
|
||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), bind: vec![] }).unwrap();
|
||||
assert_eq!(
|
||||
s.apply(Op::Gang { as_name: "solo".into(), into: vec!["a.length".into()] }),
|
||||
Err(OpError::GangArity { gang: "solo".into() })
|
||||
);
|
||||
|
||||
// unknown node: a member names a node that was never added.
|
||||
let mut s = session("g");
|
||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), bind: vec![] }).unwrap();
|
||||
assert_eq!(
|
||||
s.apply(Op::Gang { as_name: "g".into(), into: vec!["a.length".into(), "ghost.length".into()] }),
|
||||
Err(OpError::UnknownIdentifier("ghost".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finish_reports_incomplete_on_unwired_slot() {
|
||||
// a fully-named scaffold missing the sub.rhs connection -> holistic 0-arm
|
||||
|
||||
Reference in New Issue
Block a user