feat(aura-engine, aura-vocabulary, aura-cli): the args channel end to end

The data plane reaches non-scalar structural config (closes #271, the
final Data-authorability-boundary item). Op::Add gains args
(string pairs, applied via try_args after resolve and before the bind
loop — no pending builder ever enters a graph; an arg-bearing type
without args refuses as MissingArg). OpError::BadArg carries the
precise ArgOpError; the CLI renders it as prose naming the arg, its
kind, and the kind's closed hint (exit 1, #175 content-fault class).

Serde: PrimitiveData.args (skip-if-empty — args-free documents stay
byte-identical, golden-pinned, so every existing content id is stable
per C18) with a data-driven format version: the writer emits 1 for
args-free documents and 2 when any primitive recursively carries args;
the loader accepts 1..=2. The old v2-refusal pin flips deliberately to
v3 (named contract change). Identity JSON keeps args — they are
structural, and the Rust configured() path and the args op-script
bridge to the same identity id (pinned).

Roster: Session / LinComb / CostSum enter (33 -> 36, both count pins;
the LinComb-rejection assertion flips to the resolved side). introspect
--node shows arg rows (name, kind, hint) plus the pending note;
OP_REFERENCE's add row teaches the args form. Registry comment
de-staled (LinComb is now roster-reachable).

Sequencing note: this commit lands op seam and roster together so no
intermediate tree state exposes a pending builder.
This commit is contained in:
2026-07-24 21:43:02 +02:00
parent e84ad6d0d2
commit a8b1ba45c5
9 changed files with 580 additions and 108 deletions
+9
View File
@@ -65,6 +65,15 @@ zip = "2"
# (#[cfg(test)] use aura_composites::StopRule in main.rs's ic_tests module),
# so this rides the same dev-only-edge idiom as `aura-analysis`/`sha2` below.
aura-composites = { path = "../aura-composites" }
# aura-market: the shell's own use is test-only (#271's identity-bridge test,
# `identity_id_bridges_the_rust_configured_session_and_args_script`, needs
# `Session::configured` as the Rust-authored twin of the args op-script path)
# — rides the same dev-only-edge idiom as `aura-composites`/`aura-analysis`.
aura-market = { path = "../aura-market" }
# chrono-tz: the SAME identity-bridge test needs a `chrono_tz::Tz` to pass
# `Session::configured` — test-only, pinned to the same version every other
# crate's chrono-tz entry uses.
chrono-tz = { version = "0.10", default-features = false }
# aura-analysis: pearson_corr, used only by the ic_tests unit-test fixtures
# (mod ic_tests in main.rs) — a test-only edge, not a production one.
aura-analysis = { path = "../aura-analysis" }
+49 -5
View File
@@ -8,8 +8,9 @@ use std::collections::BTreeMap;
use std::path::Path;
use aura_engine::{
blueprint_from_json, blueprint_identity_json, blueprint_to_json, replay, BindOpError,
BlueprintDoc, CompileError, Composite, GraphSession, LoadError, Op, OpError, Scalar, ScalarKind,
blueprint_from_json, blueprint_identity_json, blueprint_to_json, replay, ArgOpError,
BindOpError, BlueprintDoc, CompileError, Composite, GraphSession, LoadError, Op, OpError,
Scalar, ScalarKind,
};
use serde::Deserialize;
@@ -30,6 +31,9 @@ pub const OP_REFERENCE: &str = r#"Op-list reference (stdin: a JSON array of op o
declare an open input role (wired by an enclosing graph)
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}}
instantiate a node ("name" optional; "bind" maps param -> typed scalar)
{"op":"add","type":"Session","name":"ny","args":{"tz":"America/New_York","open":"09:30"},"bind":{"period_minutes":{"I64":15}}}
an arg-bearing type applies "args" (closed, per-type-declared string
pairs) BEFORE "bind" — see graph introspect --node <T> for its args
{"op":"feed","role":"price","into":["fast.series","slow.series"]}
wire a role into one or more input slots
{"op":"connect","from":"fast.value","to":"sub.lhs"}
@@ -64,6 +68,12 @@ enum OpDoc {
// naming, not an aliasing (contrast `expose`'s `as`, which is a real alias).
#[serde(rename = "name", default)]
as_name: Option<String>,
// Construction args (#271) — the typed, closed channel applied BEFORE
// `bind`. A `BTreeMap` (deterministic iteration order into `Op::Add`'s
// `Vec`, mirroring `bind`'s own convention) — absent for every
// args-free type, so an old-style `add` document is unaffected.
#[serde(default)]
args: BTreeMap<String, String>,
#[serde(default)]
bind: BTreeMap<String, Scalar>,
},
@@ -151,9 +161,12 @@ impl From<OpDoc> for Op {
match d {
OpDoc::Source { role, kind } => Op::Source { role, kind },
OpDoc::Input { role } => Op::Input { role },
OpDoc::Add { type_id, as_name, bind } => {
Op::Add { type_id, as_name, bind: bind.into_iter().collect() }
}
OpDoc::Add { type_id, as_name, args, bind } => Op::Add {
type_id,
as_name,
args: args.into_iter().collect(),
bind: bind.into_iter().collect(),
},
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 },
@@ -172,6 +185,24 @@ impl From<OpDoc> for Op {
}
}
/// Phrase one `ArgOpError` (#271) as a human cause, WITHOUT the node prefix —
/// shared by `format_op_error`'s `BadArg` arm (the `add`-op path) and
/// `blueprint_load_prose`'s `BadArg` arm (the blueprint LOAD path), so the two
/// error families cannot phrase the same fault differently. Exhaustive over
/// `ArgOpError`; `BadValue` names the arg, its declared `ArgKind`, AND the
/// closed per-kind `hint()` line (the same hint `introspect --node` shows).
fn arg_op_error_prose(err: &ArgOpError) -> String {
match err {
ArgOpError::NotArgBearing => "takes no construction args".to_string(),
ArgOpError::UnknownArg(a) => format!("has no construction arg {a:?}"),
ArgOpError::DuplicateArg(a) => format!("was given arg {a:?} more than once"),
ArgOpError::MissingArg(a) => format!("is missing required arg {a:?}"),
ArgOpError::BadValue { arg, kind, got } => {
format!("arg {arg:?} expects {kind:?} ({}) — got {got:?}", kind.hint())
}
}
}
/// Phrase one `OpError` as a human cause (the engine error types are
/// `Display`-free by convention; this is the CLI's presentation layer). The
/// exhaustive match means a new `OpError` variant forces an update here.
@@ -204,6 +235,7 @@ fn format_op_error(e: &OpError) -> String {
format!("cannot bind {node}.{param} — member of gang {gang:?}")
}
},
OpError::BadArg { node, err } => format!("node {node} {}", arg_op_error_prose(err)),
OpError::UnconnectedPort { node, slot } => format!("slot {node}.{slot} is unconnected"),
OpError::RoleKindMismatch { role } => format!("input role {role} fans into slots of differing kinds"),
OpError::GangKindMismatch { member, expected, got } => {
@@ -439,6 +471,17 @@ pub fn introspect_node(type_id: &str, env: &aura_runner::project::Env) -> Result
let schema = builder.schema();
// C29 (#315): the head line carries the node's one-line meaning.
let mut out = format!("{}{}\n", builder.label(), schema.doc);
// #271: an arg-bearing (pending) type declares its ArgSpecs but no real
// ports/params yet — those form only once `try_args` runs (`make`). Show
// the arg rows first (they must be supplied before anything else can be
// discovered) plus the one-line note the spec's worked discovery example
// pins.
for spec in builder.arg_specs() {
out.push_str(&format!(" arg {}: {:?} ({})\n", spec.name, spec.kind, spec.kind.hint()));
}
if builder.is_pending() {
out.push_str(" note ports and params form at construction; args are required\n");
}
for port in &schema.inputs {
out.push_str(&format!(" in {}:{:?}\n", port.name, port.kind));
}
@@ -662,6 +705,7 @@ pub(crate) fn blueprint_load_prose(e: &LoadError) -> String {
}
LoadError::UnknownNodeType(t) => format!("unknown node type {t:?}"),
LoadError::Gang(e) => format!("gang section invalid: {}", gang_fault_prose(e)),
LoadError::BadArg(e) => format!("construction args invalid: {}", arg_op_error_prose(e)),
}
}
+48
View File
@@ -3809,6 +3809,54 @@ mod tests {
);
}
/// #271 (cross-path identity, extending #171's bridge pattern to an
/// arg-bearing type): the Rust-authored `Session::configured` builder and
/// its `add`-op `args` twin differ in canonical bytes (debug names) —
/// distinct content ids — but project to the same identity JSON, one
/// identity id across authoring paths even though the construction
/// channel (Rust call vs `args` object) differs.
#[test]
fn identity_id_bridges_the_rust_configured_session_and_args_script() {
use aura_market::Session;
let rust_built = Composite::new(
"sess",
vec![Session::configured(9, 30, chrono_tz::Europe::Berlin, 15).into()],
vec![],
vec![aura_engine::Role {
name: "trigger".into(),
targets: vec![aura_engine::Target { node: 0, slot: 0 }],
source: Some(ScalarKind::F64),
}],
vec![aura_engine::OutField { node: 0, field: 0, name: "bars".into() }],
);
let doc = r#"[
{"op":"source","role":"trigger","kind":"F64"},
{"op":"add","type":"Session","name":"sess",
"args":{"tz":"Europe/Berlin","open":"09:30"},
"bind":{"period_minutes":{"I64":15}}},
{"op":"feed","role":"trigger","into":["sess.trigger"]},
{"op":"expose","from":"sess.bars_since_open","as":"bars"}
]"#;
let env = aura_runner::project::Env::std();
let json = crate::graph_construct::build_from_str(doc, &env).expect("args op-script twin builds");
assert_ne!(
content_id(&json),
content_id(&blueprint_to_json(&rust_built).expect("serializes")),
"authoring paths keep distinct content ids (debug names differ)"
);
let loaded = blueprint_from_json(&json, &|t| std_vocabulary(t)).expect("twin reloads");
let identity = |c: &Composite| {
content_id(&aura_engine::blueprint_identity_json(c).expect("identity-serializes"))
};
assert_eq!(
identity(&loaded),
identity(&rust_built),
"one topology -> one identity id, Rust-configured vs args op-script"
);
}
#[test]
fn mc_r_bootstrap_json_carries_every_bootstrap_field_under_the_mc_r_bootstrap_key() {
// Property: the `mc_r_bootstrap` output line is the full RBootstrap shape —
+75 -2
View File
@@ -170,8 +170,9 @@ fn graph_introspect_vocabulary_lists_exactly_the_closed_roster_count() {
let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(
lines.len(),
33,
"the std-only (no project) vocabulary has exactly the roster's 33 entries: {stdout}"
36,
"the std-only (no project) vocabulary has exactly the roster's 36 entries (#271: \
Session/LinComb/CostSum moved in): {stdout}"
);
}
@@ -183,6 +184,78 @@ fn graph_introspect_node_answers_ports_without_a_build() {
assert!(stdout.contains("value"), "lists the output field: {stdout}");
}
/// #271 acceptance criterion 1: an op-script reaching BOTH new arg-bearing
/// roster entries (a `Session` for any IANA timezone/open, a `LinComb` of any
/// arity — no Rust authored) builds via `aura graph build`; the emitted
/// blueprint is `"version": 2` (the args-bearing tier) and carries the
/// accepted timezone verbatim.
///
/// Deviation from the spec's literal worked example
/// (`docs/specs/construction-args.md` §"The user-facing program"): that JSON
/// wires `ny.bars_since_open` (`Session`'s I64 output) directly into
/// `mix.term[0]` (`LinComb`'s F64 input) — a genuine kind mismatch the
/// engine's eager `connect` gate refuses (I64 != F64), not a property of
/// this feature. This fixture keeps every construction-args property the
/// criterion asks for (Session + args, LinComb + args, version 2, tz
/// carried) but feeds `LinComb` from two same-kind SMAs and exposes
/// `ny.bars_since_open` at the boundary directly (`expose` has no kind
/// constraint) instead of wiring it into the mismatched slot.
#[test]
fn graph_build_accepts_the_session_args_example() {
let doc = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"Session","name":"ny",
"args":{"tz":"America/New_York","open":"09:30"},
"bind":{"period_minutes":{"I64":15}}},
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
{"op":"add","type":"LinComb","name":"mix","args":{"arity":"2"},
"bind":{"weights[0]":{"F64":1.0},"weights[1]":{"F64":0.25}}},
{"op":"add","type":"Bias"},
{"op":"feed","role":"price","into":["ny.trigger","fast.series","slow.series"]},
{"op":"connect","from":"fast.value","to":"mix.term[0]"},
{"op":"connect","from":"slow.value","to":"mix.term[1]"},
{"op":"connect","from":"mix.value","to":"bias.signal"},
{"op":"expose","from":"bias.bias","as":"bias"},
{"op":"expose","from":"ny.bars_since_open","as":"session_bar"},
{"op":"doc","text":"NY-session-gated SMA blend"}
]"#;
let (stdout, stderr, ok) = run(&["graph", "build"], doc);
assert!(ok, "the worked example builds: {stderr}");
assert!(stdout.contains(r#""format_version":2"#), "an args-bearing document is version 2: {stdout}");
assert!(stdout.contains("America/New_York"), "carries the accepted tz verbatim: {stdout}");
}
/// #271: a malformed construction-arg value (a bad IANA tz string) refuses at
/// the `add` op with exit 1, naming the offending arg (not a Debug leak).
#[test]
fn graph_build_bad_tz_is_exit_1_naming_the_arg() {
let doc = r#"[
{"op":"add","type":"Session","name":"ny",
"args":{"tz":"not_a_zone","open":"09:30"}}
]"#;
let (stderr, code) = run_code(&["graph", "build"], doc);
assert_eq!(code, Some(1), "a construction-arg fault is exit 1: {stderr}");
assert!(stderr.contains("tz"), "names the offending arg: {stderr}");
assert!(!stderr.contains("{"), "no Debug-struct leak: {stderr}");
}
/// #271: `graph introspect --node Session` self-describes its declared
/// construction args (name, kind, hint) plus the pending note — the
/// discovery surface an author reads before writing the `args` object.
#[test]
fn graph_introspect_node_session_lists_arg_rows() {
let (stdout, _stderr, ok) = run(&["graph", "introspect", "--node", "Session"], "");
assert!(ok, "exit success");
assert!(stdout.contains("tz"), "lists the tz arg: {stdout}");
assert!(stdout.contains("open"), "lists the open arg: {stdout}");
assert!(stdout.contains("IANA timezone"), "shows the tz hint: {stdout}");
assert!(
stdout.contains("ports and params form at construction"),
"shows the pending note: {stdout}"
);
}
#[test]
fn graph_introspect_unwired_reports_open_slots() {
let doc = r#"[
+219 -15
View File
@@ -7,18 +7,29 @@
//! closure and the declared schema are re-derived on load from the injected
//! 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).
//!
//! Construction args (#271) are structural, id-bearing data — like `bound`
//! values, unlike debug-symbol names — so `strip_debug_symbols` leaves
//! `PrimitiveData.args` untouched.
use crate::blueprint::{BlueprintNode, Composite, Gang, OutField, Role};
use crate::harness::Edge;
use aura_core::{BoundParam, PrimitiveBuilder};
use aura_core::{BoundParam, ConstructionArg, PrimitiveBuilder};
/// The format version the loader understands. Bumped only by a load-bearing
/// (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;
/// The CEILING format version this build's loader understands (and the
/// writer may ever emit) — bumped only by a load-bearing (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.
///
/// #271 (data-driven version): the writer no longer emits a single fixed
/// version. A document emits `1` when args-free (byte-identical to every
/// pre-#271 document — content ids stable, C18) and `2` the moment any
/// primitive, at any nesting depth, carries construction `args` — the
/// must-understand signal a pre-#271 loader needs. The loader accepts the
/// closed range `1..=BLUEPRINT_FORMAT_VERSION`.
pub const BLUEPRINT_FORMAT_VERSION: u32 = 2;
/// Top-level envelope: the version is read before the payload is interpreted.
#[derive(serde::Serialize, serde::Deserialize)]
@@ -61,17 +72,33 @@ pub enum NodeData {
}
/// A primitive node as data: its compiled-in type identity, optional instance
/// name, and bound params. The schema + build closure are re-derived on load.
/// name, its accepted construction args (#271), and bound params. The schema
/// + build closure are re-derived on load.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct PrimitiveData {
#[serde(rename = "type")]
pub type_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// The consumed `(name, value)` construction-arg pairs (#271) — the
/// id-bearing, serialized twin of `bound`. Additive-optional: absent
/// (empty) for every args-free primitive, so a document with no args
/// anywhere stays byte-identical to its pre-#271 form.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<ArgData>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub bound: Vec<BoundParam>,
}
/// One serialized construction-arg pair (#271) — the wire twin of
/// [`aura_core::ConstructionArg`]: `value` is the accepted strict-form
/// string, stored verbatim (never re-normalized).
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct ArgData {
pub name: String,
pub value: String,
}
/// Serializer failure (typed, named).
#[derive(Debug)]
pub enum SerializeError {
@@ -111,9 +138,19 @@ fn project_node(n: &BlueprintNode) -> NodeData {
// construction once a multi-param node enters the vocabulary.
let mut bound = b.bound_params().to_vec();
bound.sort_by_key(|bp| bp.pos);
// Construction args (#271) are declared in a fixed `ArgSpec` order
// (not player-chosen like binds), so no re-canonicalization is
// needed — `construction_args()` already returns them in that
// deterministic order.
let args: Vec<ArgData> = b
.construction_args()
.iter()
.map(|ConstructionArg { name, value }| ArgData { name: name.clone(), value: value.clone() })
.collect();
NodeData::Primitive(PrimitiveData {
type_id: b.label(),
name: b.instance_name().map(str::to_string),
args,
bound,
})
}
@@ -121,8 +158,32 @@ fn project_node(n: &BlueprintNode) -> NodeData {
}
}
/// Any primitive, at any nesting depth, carries construction args (#271) —
/// the must-understand signal for the data-driven version (spec §Data-driven
/// format version).
fn has_args(b: &CompositeData) -> bool {
b.nodes.iter().any(|n| match n {
NodeData::Primitive(p) => !p.args.is_empty(),
NodeData::Composite(c) => has_args(c),
})
}
/// The version THIS document must declare (#271): `1` for an args-free
/// document (byte-identical to every pre-#271 document, C18) or `2` the
/// moment any primitive anywhere carries args — never the fixed
/// `BLUEPRINT_FORMAT_VERSION` ceiling.
fn document_version(b: &CompositeData) -> u32 {
if has_args(b) {
2
} else {
1
}
}
fn build_doc(c: &Composite) -> BlueprintDoc {
BlueprintDoc { format_version: BLUEPRINT_FORMAT_VERSION, blueprint: project(c) }
let blueprint = project(c);
let format_version = document_version(&blueprint);
BlueprintDoc { format_version, blueprint }
}
fn serialize_doc(doc: &BlueprintDoc) -> Result<String, SerializeError> {
@@ -200,11 +261,16 @@ pub enum LoadError {
/// addition needs finer granularity than a version bump.
UnsupportedVersion { found: u32, supported: u32 },
/// `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).
/// vocabulary — unknown node, or a 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),
/// A primitive's `args` (#271) failed `try_args`: unknown/duplicate/
/// missing arg, a malformed value, OR (the pending-with-no-args refusal)
/// an arg-bearing type serialized/hand-written with an empty `args` —
/// a document naming an arg-bearing type without args must not load.
BadArg(aura_core::ArgOpError),
}
fn reconstruct(
@@ -220,6 +286,15 @@ fn reconstruct(
if let Some(n) = &p.name {
b = b.named(n);
}
// Apply construction args (#271) BEFORE bound params — the
// `try_args` seam. An arg-bearing type with no `args` in the
// document refuses HERE as `MissingArg` (no pending builder
// ever reaches `bind`/enters the built graph); a `Plain` type
// has empty `p.args`, so this is `Ok(self)` unchanged for
// every pre-#271 document.
let args: Vec<(String, String)> =
p.args.iter().map(|a| (a.name.clone(), a.value.clone())).collect();
b = b.try_args(&args).map_err(LoadError::BadArg)?;
// Re-apply bound params BY NAME. The effective param vector is
// order-independent (each `bind` computes its slot against the
// shrunk schema); ascending original `pos` is the canonical order.
@@ -251,7 +326,9 @@ pub fn blueprint_from_json(
resolve: &dyn Fn(&str) -> Option<PrimitiveBuilder>,
) -> Result<Composite, LoadError> {
let doc: BlueprintDoc = serde_json::from_str(data).map_err(LoadError::Json)?;
if doc.format_version != BLUEPRINT_FORMAT_VERSION {
// #271: the loader accepts the closed range 1..=BLUEPRINT_FORMAT_VERSION
// (today 1..=2) — a data-driven ceiling, not a single fixed version.
if !(1..=BLUEPRINT_FORMAT_VERSION).contains(&doc.format_version) {
return Err(LoadError::UnsupportedVersion {
found: doc.format_version,
supported: BLUEPRINT_FORMAT_VERSION,
@@ -266,7 +343,8 @@ mod tests {
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};
use aura_core::{ArgOpError, Scalar, ScalarKind, Timestamp};
use aura_market::Session;
use aura_std::{Recorder, Sma, Sub};
use aura_strategy::Bias;
use std::sync::mpsc;
@@ -436,11 +514,14 @@ mod tests {
assert!(matches!(err, LoadError::UnknownNodeType(t) if t == "NoSuchNode"));
}
/// #271 flipped pin (named contract change, not a regression): `format_version: 2`
/// used to be refused (v1 was the only understood version); now that v2 loads
/// (the args-bearing tier), only a version PAST the new ceiling is unsupported.
#[test]
fn unsupported_version_fails_named() {
let json = r#"{"format_version":2,"blueprint":{"name":"x","nodes":[]}}"#;
let json = r#"{"format_version":3,"blueprint":{"name":"x","nodes":[]}}"#;
let err = blueprint_from_json(json, &|t| aura_vocabulary::std_vocabulary(t)).err().unwrap();
assert!(matches!(err, LoadError::UnsupportedVersion { found: 2, supported: 1 }));
assert!(matches!(err, LoadError::UnsupportedVersion { found: 3, supported: 2 }));
}
#[test]
@@ -813,4 +894,127 @@ mod tests {
"the gang itself is identity-bearing"
);
}
// ---- construction args (#271) --------------------------------------
// A single-node composite around an arg-configured `Session` (arity-free —
// a `trigger` input role feeds it, its `bars_since_open` output re-exports).
fn session_arg_fixture(tz: chrono_tz::Tz) -> Composite {
Composite::new(
"sess",
vec![Session::configured(9, 30, tz, 15).into()],
vec![],
vec![Role {
name: "trigger".into(),
targets: vec![Target { node: 0, slot: 0 }],
source: Some(ScalarKind::F64),
}],
vec![OutField { node: 0, field: 0, name: "bars".into() }],
)
}
/// #271 acceptance (engine layer): an args-bearing composite serializes its
/// construction-arg pairs, declares `format_version: 2` (the data-driven
/// must-understand tier, spec §Data-driven format version), and round-trips
/// byte-stably through load -> re-serialize.
#[test]
fn args_round_trip_preserves_pairs_and_version_2() {
let c = session_arg_fixture(chrono_tz::Europe::Berlin);
let json = blueprint_to_json(&c).expect("serializes");
assert!(json.contains(r#""format_version":2"#), "args-bearing document is version 2: {json}");
assert!(
json.contains(r#""args":[{"name":"tz","value":"Europe/Berlin"},{"name":"open","value":"09:30"}]"#),
"carries the verbatim accepted arg pairs: {json}"
);
let loaded = blueprint_from_json(&json, &fixture_resolver()).expect("loads");
assert_eq!(
blueprint_to_json(&loaded).expect("re-serializes"),
json,
"args round-trip byte-stably"
);
}
/// #271 acceptance (C18 stability): a document with NO args anywhere stays
/// version 1 and byte-identical to its pre-#271 canonical form — the exact
/// golden `signal_serializes_to_canonical_golden` already pins, re-asserted
/// here under the #271-specific test name the plan names.
#[test]
fn args_free_document_serializes_byte_identical_and_version_1() {
let signal = crate::test_fixtures::sink_free_sma_cross_signal();
let json = blueprint_to_json(&signal).expect("serializes");
assert!(json.starts_with(r#"{"format_version":1,"#), "args-free document stays version 1: {json}");
assert!(!json.contains("\"args\""), "an args-free document emits no args key: {json}");
let golden = r#"{"format_version":1,"blueprint":{"name":"sma_cross","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}]}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
assert_eq!(json, golden, "byte-identical to the pre-#271 canonical form (content ids stable, C18)");
}
/// #271 (nested propagation): an args-bearing primitive nested INSIDE an
/// outer composite still trips the outer document's version to 2 — the
/// version walk recurses through `NodeData::Composite`, not just the
/// top-level node list.
#[test]
fn nested_spliced_args_composite_is_version_2() {
let inner = session_arg_fixture(chrono_tz::Europe::Berlin);
let outer = Composite::new(
"outer",
vec![crate::blueprint::BlueprintNode::Composite(inner)],
vec![],
vec![Role {
name: "trigger".into(),
targets: vec![Target { node: 0, slot: 0 }],
source: Some(ScalarKind::F64),
}],
vec![OutField { node: 0, field: 0, name: "bars".into() }],
);
let json = blueprint_to_json(&outer).expect("serializes");
assert!(json.contains(r#""format_version":2"#), "a nested arg-bearing primitive still trips version 2: {json}");
assert!(json.contains("\"args\""), "the nested primitive's args survive: {json}");
}
/// #271 (identity is arg-bearing): construction args are structural,
/// id-bearing data — like a bound value, unlike a debug-symbol name — so
/// two builds differing ONLY in one accepted arg value never share an
/// identity JSON, while renaming the composite (a pure debug symbol)
/// still does not affect it.
#[test]
fn identity_json_retains_args() {
let berlin = session_arg_fixture(chrono_tz::Europe::Berlin);
let paris = session_arg_fixture(chrono_tz::Europe::Paris);
assert_ne!(
blueprint_identity_json(&berlin).expect("identity-serializes"),
blueprint_identity_json(&paris).expect("identity-serializes"),
"a differing construction-arg value survives the identity projection"
);
// renaming the composite (a debug symbol) does not affect identity.
let renamed = Composite::new(
"sess_renamed",
vec![Session::configured(9, 30, chrono_tz::Europe::Berlin, 15).into()],
vec![],
vec![Role {
name: "trigger".into(),
targets: vec![Target { node: 0, slot: 0 }],
source: Some(ScalarKind::F64),
}],
vec![OutField { node: 0, field: 0, name: "bars".into() }],
);
assert_eq!(
blueprint_identity_json(&berlin).expect("identity-serializes"),
blueprint_identity_json(&renamed).expect("identity-serializes"),
"the composite's own debug name does not affect identity"
);
}
/// #271 (refuse, don't guess): a hand-written document naming an
/// arg-bearing type (`Session`) with NO `args` key refuses on load as
/// `LoadError::BadArg(ArgOpError::MissingArg(..))` — a pending builder
/// must never silently enter the built graph.
#[test]
fn loading_arg_bearing_type_without_args_refuses() {
let json = r#"{"format_version":1,"blueprint":{"name":"x","nodes":[{"primitive":{"type":"Session"}}]}}"#;
let err = blueprint_from_json(json, &fixture_resolver()).err().unwrap();
assert!(
matches!(&err, LoadError::BadArg(ArgOpError::MissingArg(a)) if a == "tz"),
"an arg-bearing type with no args refuses as MissingArg, got {err:?}"
);
}
}
+139 -57
View File
@@ -13,7 +13,7 @@
use std::collections::{HashMap, HashSet};
use aura_core::{BindOpError, NodeSchema, PrimitiveBuilder, Scalar, ScalarKind};
use aura_core::{ArgOpError, BindOpError, NodeSchema, PrimitiveBuilder, Scalar, ScalarKind};
use crate::blueprint::{
check_param_namespace_injective, edge_kind_check, validate_wiring, BlueprintNode, Composite,
@@ -32,8 +32,14 @@ pub enum Op {
Source { role: String, kind: ScalarKind },
/// Reserve an open input role (wired by an enclosing graph).
Input { role: String },
/// Add a node of `type_id`, optionally named `as_name`, with `bind` params.
Add { type_id: String, as_name: Option<String>, bind: Vec<(String, Scalar)> },
/// Add a node of `type_id`, optionally named `as_name`, with construction
/// `args` (the typed, closed channel — #271) applied BEFORE `bind` params.
Add {
type_id: String,
as_name: Option<String>,
args: Vec<(String, String)>,
bind: Vec<(String, Scalar)>,
},
/// Fan a role into one or more `"node.slot"` consumers.
Feed { role: String, into: Vec<String> },
/// Wire `"node.field"` -> `"node.slot"`.
@@ -91,6 +97,11 @@ pub enum OpError {
/// blueprint non-acyclic (domain invariant 5 / ledger C9).
WouldCycle { from: String, to: String },
BadParam { node: String, err: BindOpError },
/// A construction-arg fault (#271): an arg-bearing type's `args` failed
/// `try_args` (unknown/duplicate/missing arg, or a malformed value) — or a
/// `Plain` type was given args it does not declare. By-identifier, naming
/// the offending node.
BadArg { node: String, err: ArgOpError },
/// Holistic totality fault, by-identifier: an interior input slot covered by
/// no edge or role target (the finalize 0-cover arm).
UnconnectedPort { node: String, slot: String },
@@ -180,7 +191,7 @@ impl<'v> GraphSession<'v> {
match op {
Op::Source { role, kind } => self.add_role(role, Some(kind)),
Op::Input { role } => self.add_role(role, None),
Op::Add { type_id, as_name, bind } => self.add_node(type_id, as_name, bind),
Op::Add { type_id, as_name, args, bind } => self.add_node(type_id, as_name, args, bind),
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),
@@ -248,11 +259,17 @@ impl<'v> GraphSession<'v> {
&mut self,
type_id: String,
as_name: Option<String>,
args: Vec<(String, String)>,
bind: Vec<(String, Scalar)>,
) -> Result<(), OpError> {
let mut builder =
(self.vocab)(&type_id).ok_or_else(|| OpError::UnknownNodeType(type_id.clone()))?;
let id = as_name.unwrap_or_else(|| builder.node_name());
// Construction args apply BEFORE the bind loop (spec §aura-engine): a
// pending arg-bearing builder with no args therefore refuses here as
// `MissingArg` — no pending builder ever reaches `try_bind`/enters the
// graph.
builder = builder.try_args(&args).map_err(|err| OpError::BadArg { node: id.clone(), err })?;
for (slot, value) in bind {
builder = builder
.try_bind(&slot, value)
@@ -564,7 +581,7 @@ pub fn replay(
mod tests {
use super::{GraphSession, Op, OpError};
use crate::blueprint::{BlueprintNode, Composite, Role};
use aura_core::{BindOpError, Scalar, ScalarKind};
use aura_core::{ArgKind, ArgOpError, BindOpError, Scalar, ScalarKind};
use aura_vocabulary::std_vocabulary;
fn session(name: &str) -> GraphSession<'static> {
@@ -575,7 +592,7 @@ mod tests {
fn add_unknown_type_rejected_at_op() {
let mut s = session("g");
assert_eq!(
s.apply(Op::Add { type_id: "Nope".into(), as_name: None, bind: vec![] }),
s.apply(Op::Add { type_id: "Nope".into(), as_name: None, args: vec![], bind: vec![] }),
Err(OpError::UnknownNodeType("Nope".into()))
);
}
@@ -583,9 +600,9 @@ mod tests {
#[test]
fn add_duplicate_identifier_rejected() {
let mut s = session("g");
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("x".into()), bind: vec![] }).unwrap();
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("x".into()), args: vec![], bind: vec![] }).unwrap();
assert_eq!(
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("x".into()), bind: vec![] }),
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("x".into()), args: vec![], bind: vec![] }),
Err(OpError::DuplicateIdentifier("x".into()))
);
}
@@ -597,7 +614,7 @@ mod tests {
s.apply(Op::Add {
type_id: "SMA".into(),
as_name: Some("fast".into()),
bind: vec![("length".into(), Scalar::f64(2.0))], // wrong kind (i64 param)
args: vec![], bind: vec![("length".into(), Scalar::f64(2.0))], // wrong kind (i64 param)
}),
Err(OpError::BadParam {
node: "fast".into(),
@@ -624,9 +641,9 @@ mod tests {
fn scaffold() -> GraphSession<'static> {
let mut s = session("g");
s.apply(Op::Source { role: "price".into(), kind: ScalarKind::F64 }).unwrap();
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] }).unwrap();
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] }).unwrap();
s.apply(Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] }).unwrap();
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), args: vec![], bind: vec![] }).unwrap();
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), args: vec![], bind: vec![] }).unwrap();
s.apply(Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] }).unwrap();
s
}
@@ -653,8 +670,8 @@ mod tests {
fn connect_kind_mismatch_rejected_at_op() {
// Gt produces bool; Bias.signal consumes f64 -> eager kind mismatch
let mut s = session("g");
s.apply(Op::Add { type_id: "Gt".into(), as_name: None, bind: vec![] }).unwrap();
s.apply(Op::Add { type_id: "Bias".into(), as_name: None, bind: vec![] }).unwrap();
s.apply(Op::Add { type_id: "Gt".into(), as_name: None, args: vec![], bind: vec![] }).unwrap();
s.apply(Op::Add { type_id: "Bias".into(), as_name: None, args: vec![], bind: vec![] }).unwrap();
let r = s.apply(Op::Connect { from: "gt.value".into(), to: "bias.signal".into() });
assert!(matches!(
r,
@@ -820,9 +837,9 @@ mod tests {
use crate::harness::FlatTap;
let ops = vec![
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] },
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] },
Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] },
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), args: vec![], bind: vec![] },
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), args: vec![], bind: vec![] },
Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] },
Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] },
Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() },
Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() },
@@ -842,10 +859,10 @@ mod tests {
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::Add { type_id: "SMA".into(), as_name: Some("a".into()), args: vec![], bind: vec![] },
Op::Add { type_id: "SMA".into(), as_name: Some("b".into()), args: vec![], 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::Add { type_id: "Sub".into(), as_name: None, args: vec![], 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() },
@@ -863,7 +880,7 @@ mod tests {
/// i64` session-index output, wires its `trigger` input through the ordinary
/// edge convention, and builds to a finished `Composite`. Reachability is the
/// whole ask — the node's tz-aware/DST session SEMANTICS are already pinned
/// in `aura-std/src/session.rs` and are not re-pinned here. Today the preset
/// in `aura-market/src/session.rs` and are not re-pinned here. Today the preset
/// id is absent from the roster (the base `Session` builder takes structural
/// construction args, so the zero-arg macro cannot host it), so the lookup
/// returns `None` and the op-script's `Add` would fail `UnknownNodeType`.
@@ -892,7 +909,7 @@ mod tests {
Op::Add {
type_id: "SessionFrankfurt".into(),
as_name: Some("session".into()),
bind: vec![("period_minutes".into(), Scalar::i64(15))],
args: vec![], bind: vec![("period_minutes".into(), Scalar::i64(15))],
},
Op::Feed { role: "trigger".into(), into: vec!["session.trigger".into()] },
Op::Expose { from: "session.bars_since_open".into(), as_name: "bars".into() },
@@ -909,11 +926,11 @@ mod tests {
// 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("a".into()), args: vec![], bind: vec![] }).unwrap();
s.apply(Op::Add {
type_id: "SMA".into(),
as_name: Some("b".into()),
bind: vec![("length".into(), Scalar::i64(5))],
args: vec![], bind: vec![("length".into(), Scalar::i64(5))],
})
.unwrap();
assert_eq!(
@@ -923,8 +940,8 @@ mod tests {
// 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();
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), args: vec![], bind: vec![] }).unwrap();
s.apply(Op::Add { type_id: "Bias".into(), as_name: Some("b".into()), args: vec![], 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, .. })),
@@ -933,9 +950,9 @@ mod tests {
// 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::Add { type_id: "SMA".into(), as_name: Some("a".into()), args: vec![], bind: vec![] }).unwrap();
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("b".into()), args: vec![], bind: vec![] }).unwrap();
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("c".into()), args: vec![], 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()] }),
@@ -944,7 +961,7 @@ mod tests {
// 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();
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), args: vec![], bind: vec![] }).unwrap();
assert_eq!(
s.apply(Op::Gang { as_name: "solo".into(), into: vec!["a.length".into()] }),
Err(OpError::GangArity { gang: "solo".into() })
@@ -952,7 +969,7 @@ mod tests {
// 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();
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), args: vec![], 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()))
@@ -1010,7 +1027,7 @@ mod tests {
fn finish_accepts_an_open_input_role_and_compile_refuses_it() {
let mut s = session("g");
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()), args: vec![], bind: vec![] }).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();
let c = s.finish().expect("finish() no longer gates root-role boundness");
@@ -1030,8 +1047,8 @@ mod tests {
fn finish_reports_role_kind_mismatch_by_identifier() {
let mut s = session("g");
s.apply(Op::Source { role: "price".into(), kind: ScalarKind::F64 }).unwrap();
s.apply(Op::Add { type_id: "Sub".into(), as_name: Some("sub".into()), bind: vec![] }).unwrap();
s.apply(Op::Add { type_id: "And".into(), as_name: Some("conj".into()), bind: vec![] }).unwrap();
s.apply(Op::Add { type_id: "Sub".into(), as_name: Some("sub".into()), args: vec![], bind: vec![] }).unwrap();
s.apply(Op::Add { type_id: "And".into(), as_name: Some("conj".into()), args: vec![], bind: vec![] }).unwrap();
// price fans into sub.lhs (f64) AND conj.a (bool): differing slot kinds.
s.apply(Op::Feed { role: "price".into(), into: vec!["sub.lhs".into(), "conj.a".into()] }).unwrap();
let err = s.finish().err().unwrap();
@@ -1065,8 +1082,8 @@ mod tests {
// — only an acyclicity check can catch it.
let ops = vec![
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
Op::Add { type_id: "Sub".into(), as_name: Some("a".into()), bind: vec![] },
Op::Add { type_id: "Sub".into(), as_name: Some("b".into()), bind: vec![] },
Op::Add { type_id: "Sub".into(), as_name: Some("a".into()), args: vec![], bind: vec![] },
Op::Add { type_id: "Sub".into(), as_name: Some("b".into()), args: vec![], bind: vec![] },
Op::Feed { role: "price".into(), into: vec!["a.rhs".into(), "b.rhs".into()] },
Op::Connect { from: "a.value".into(), to: "b.lhs".into() }, // op 4: a -> b
Op::Connect { from: "b.value".into(), to: "a.lhs".into() }, // op 5: closes b -> a
@@ -1097,7 +1114,7 @@ mod tests {
// loop. Both slots covered, role bound — only an acyclicity check catches it.
let ops = vec![
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
Op::Add { type_id: "Sub".into(), as_name: Some("s".into()), bind: vec![] },
Op::Add { type_id: "Sub".into(), as_name: Some("s".into()), args: vec![], bind: vec![] },
Op::Feed { role: "price".into(), into: vec!["s.rhs".into()] },
Op::Connect { from: "s.value".into(), to: "s.lhs".into() }, // self-edge
Op::Expose { from: "s.value".into(), as_name: "out".into() },
@@ -1111,9 +1128,9 @@ mod tests {
#[test]
fn replay_stops_at_first_failing_op_naming_index() {
let ops = vec![
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] },
Op::Add { type_id: "Nope".into(), as_name: None, bind: vec![] }, // op 1 fails
Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] },
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), args: vec![], bind: vec![] },
Op::Add { type_id: "Nope".into(), as_name: None, args: vec![], bind: vec![] }, // op 1 fails
Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] },
];
// `.err().unwrap()` rather than `.unwrap_err()`: the Ok arm `Composite` is
// not `Debug` (it holds build closures), which `unwrap_err` would require.
@@ -1141,10 +1158,10 @@ mod tests {
// (a) op-script replay
let ops = vec![
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] },
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] },
Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] },
Op::Add { type_id: "Bias".into(), as_name: None, bind: vec![] },
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), args: vec![], bind: vec![] },
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), args: vec![], bind: vec![] },
Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] },
Op::Add { type_id: "Bias".into(), as_name: None, args: vec![], bind: vec![] },
Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] },
Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() },
Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() },
@@ -1189,10 +1206,10 @@ mod tests {
// (a) op-script: the Task-4 fixture (two SMA + gang + Sub + expose).
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::Add { type_id: "SMA".into(), as_name: Some("a".into()), args: vec![], bind: vec![] },
Op::Add { type_id: "SMA".into(), as_name: Some("b".into()), args: vec![], 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::Add { type_id: "Sub".into(), as_name: None, args: vec![], 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() },
@@ -1241,9 +1258,9 @@ mod tests {
// (a) op-script: an SMA crossover with a tap on the raw spread (sub.value).
let ops = vec![
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] },
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] },
Op::Add { type_id: "Sub".into(), as_name: Some("sub".into()), bind: vec![] },
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), args: vec![], bind: vec![] },
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), args: vec![], bind: vec![] },
Op::Add { type_id: "Sub".into(), as_name: Some("sub".into()), args: vec![], bind: vec![] },
Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] },
Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() },
Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() },
@@ -1277,6 +1294,71 @@ mod tests {
assert_eq!(replay_flat.edges, built_flat.edges);
}
// ---- construction args (#271) -------------------------------------
/// `add_node` applies `try_args` AFTER resolve and BEFORE the bind loop
/// (spec §aura-engine): an arg-bearing type's `add` op, given a valid
/// `args` set, configures the real signature and the subsequent `bind`
/// sees the now-real `period_minutes` param — a data-only op-script
/// reaches a `Session` for any IANA timezone/open, not just the
/// `SessionFrankfurt` preset.
#[test]
fn add_op_with_args_builds_a_session() {
let mut s = session("g");
s.apply(Op::Source { role: "trigger".into(), kind: ScalarKind::F64 }).unwrap();
assert_eq!(
s.apply(Op::Add {
type_id: "Session".into(),
as_name: Some("ny".into()),
args: vec![("tz".into(), "America/New_York".into()), ("open".into(), "09:30".into())],
bind: vec![("period_minutes".into(), Scalar::i64(15))],
}),
Ok(()),
);
s.apply(Op::Feed { role: "trigger".into(), into: vec!["ny.trigger".into()] }).unwrap();
s.apply(Op::Expose { from: "ny.bars_since_open".into(), as_name: "bars".into() }).unwrap();
s.finish().expect("an args-configured Session finishes and builds");
}
/// `Session` is arg-bearing (declares `tz`/`open`): an `add` op naming it
/// with NO `args` refuses at the op as `BadArg(MissingArg(..))` — the
/// pending builder never reaches the bind loop or the graph (spec
/// §aura-engine: "a pending resolve with no args therefore fails as
/// MissingArg").
#[test]
fn add_op_arg_bearing_type_without_args_refuses() {
let mut s = session("g");
assert_eq!(
s.apply(Op::Add {
type_id: "Session".into(),
as_name: Some("ny".into()),
args: vec![],
bind: vec![],
}),
Err(OpError::BadArg { node: "ny".into(), err: ArgOpError::MissingArg("tz".into()) }),
);
}
/// A malformed arg value (strict-form refusal, spec §Closedness) surfaces
/// as `BadArg(BadValue{..})`, naming the arg, its declared `ArgKind`, and
/// the rejected string.
#[test]
fn add_op_bad_tz_refuses() {
let mut s = session("g");
assert_eq!(
s.apply(Op::Add {
type_id: "Session".into(),
as_name: Some("ny".into()),
args: vec![("tz".into(), "not_a_zone".into()), ("open".into(), "09:30".into())],
bind: vec![],
}),
Err(OpError::BadArg {
node: "ny".into(),
err: ArgOpError::BadValue { arg: "tz".into(), kind: ArgKind::Tz, got: "not_a_zone".into() },
}),
);
}
// ---- Op::Use (#317) -----------------------------------------------
/// A small two-node fixture composite for the `Op::Use` tests (#317): an
@@ -1412,7 +1494,7 @@ mod tests {
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();
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("gate".into()), args: vec![], bind: vec![] }).unwrap();
assert_eq!(
s.apply(Op::Use { ref_id: "fixture".into(), name: Some("gate".into()), bind: vec![] }),
Err(OpError::DuplicateIdentifier("gate".into()))
@@ -1448,13 +1530,13 @@ mod tests {
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::Add { type_id: "SMA".into(), as_name: Some("channel_hi".into()), args: vec![], bind: vec![] },
Op::Add { type_id: "SMA".into(), as_name: Some("channel_lo".into()), args: vec![], 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::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] },
Op::Feed {
role: "price".into(),
into: vec!["channel_hi.series".into(), "channel_lo.series".into()],
@@ -1505,7 +1587,7 @@ mod tests {
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::Add { type_id: "SMA".into(), as_name: Some("solo".into()), bind: vec![] })
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("solo".into()), args: vec![], bind: vec![] })
.unwrap();
assert_eq!(
s.apply(Op::Gang {
@@ -1527,7 +1609,7 @@ mod tests {
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::Add { type_id: "Sub".into(), as_name: Some("s".into()), args: vec![], 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!(
@@ -1595,7 +1677,7 @@ mod tests {
Op::Add {
type_id: "SMA".into(),
as_name: Some("sma".into()),
bind: vec![("length".into(), Scalar::i64(3))],
args: vec![], 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() },
+4 -3
View File
@@ -56,8 +56,8 @@ pub use blueprint::{
GangMember, OutField, RandomBinder, ReopenError, Role, SweepBinder, Tap, TapWire,
};
pub use blueprint_serde::{
blueprint_from_json, blueprint_identity_json, blueprint_to_json, BlueprintDoc, CompositeData,
LoadError, NodeData, PrimitiveData, SerializeError, BLUEPRINT_FORMAT_VERSION,
blueprint_from_json, blueprint_identity_json, blueprint_to_json, ArgData, BlueprintDoc,
CompositeData, LoadError, NodeData, PrimitiveData, SerializeError, BLUEPRINT_FORMAT_VERSION,
};
pub use builder::{BuildError, GraphBuilder, InPort, NodeHandle, OutPort, RoleHandle};
pub use construction::{replay, GraphSession, Op, OpError};
@@ -88,7 +88,8 @@ pub use walkforward::{
// (SourceSpec.kind is a ScalarKind; sources/Recorder columns are Scalar /
// Firing / Timestamp) so a graph builder has one import surface, not two.
pub use aura_core::{
BindOpError, Firing, NodeSchema, ParamSpec, PortSpec, Scalar, ScalarKind, Timestamp,
ArgKind, ArgOpError, BindOpError, Firing, NodeSchema, ParamSpec, PortSpec, Scalar, ScalarKind,
Timestamp,
};
#[cfg(test)]
+5 -7
View File
@@ -2154,14 +2154,12 @@ mod tests {
let resolve = |t: &str| aura_vocabulary::std_vocabulary(t);
// A minimal fixture composite with one OPEN param, built from a
// zero-arg `aura-std` node (`Bias`) so `std_vocabulary` can actually
// plain `aura-std` node (`Bias`) so `std_vocabulary` can actually
// resolve it on load. `aura-composites`' shipped composites (vol_stop,
// risk_executor*) all route through `LinComb`, whose builder needs a
// structural arity argument and is therefore deliberately absent from
// the zero-arg `std_vocabulary` roster (see aura-std/src/vocabulary.rs)
// — they cannot round-trip through `blueprint_from_json` with this
// resolver, so this fixture stands in as the "real, open-param,
// vocabulary-loadable composite" the test needs.
// risk_executor*) route through `LinComb`, an arg-bearing type (#271,
// `aura-vocabulary/src/lib.rs`) — using `Bias` here keeps this fixture
// independent of that construction channel, so it stands in as the
// "real, open-param, vocabulary-loadable composite" the test needs.
let composite = Composite::new(
"fixture",
vec![aura_strategy::Bias::builder().named("b").into()],
+32 -19
View File
@@ -8,22 +8,27 @@
//! takes it as an injected resolver, and a project `cdylib` later supplies its
//! own (C16) through the same seam.
//!
//! Scope (#155): only the **zero-argument** `Type::builder()` factories are
//! resolvable. Builders that take structural construction arguments
//! (`LinComb(arity)`, `CostSum(n_costs)`, `SimBroker(pip_size)`, `Session(..)`)
//! and the recording sinks (`Recorder`/`GatedRecorder`/`SeriesReducer`, which
//! capture an `mpsc::Sender`) are deliberately absent — an absent `type_id`
//! Scope (#155, extended #271): the roster resolves any type whose factory is
//! `Type::builder()` with zero RUST-side arguments — this now includes
//! arg-bearing types (`Session`, `LinComb`, `CostSum`), whose `builder()` is
//! itself zero-arg and returns a *pending* `PrimitiveBuilder`, configured
//! through the typed `args` channel (`try_args`) at the `add` op, before any
//! bind. `SimBroker(pip_size)` stays out of scope: it is a plain scalar Rust
//! parameter, not a structural construction arg — a different (unaddressed)
//! gap. The recording sinks (`Recorder`/`GatedRecorder`/`SeriesReducer`, which
//! capture an `mpsc::Sender`) stay deliberately absent — an absent `type_id`
//! yields `None`, so the loader fails cleanly (`LoadError::UnknownNodeType`)
//! rather than guessing. Serialising structural-axis construction args is a
//! later, additive extension (#156/C20).
//! rather than guessing.
use aura_backtest::PositionManagement;
use aura_market::{Resample, SessionFrankfurt};
use aura_market::{Resample, Session, SessionFrankfurt};
use aura_std::{
Abs, Add, And, Const, CumSum, Delay, Div, Ema, EqConst, Gt, Latch, Max, Min, Mul, RollingMax,
RollingMin, Scale, Select, Sign, Sma, Sqrt, Sub, When,
Abs, Add, And, Const, CumSum, Delay, Div, Ema, EqConst, Gt, Latch, LinComb, Max, Min, Mul,
RollingMax, RollingMin, Scale, Select, Sign, Sma, Sqrt, Sub, When,
};
use aura_strategy::{
Bias, CarryCost, ConstantCost, CostSum, FixedStop, LongOnly, Sizer, VolSlippageCost,
};
use aura_strategy::{Bias, CarryCost, ConstantCost, FixedStop, LongOnly, Sizer, VolSlippageCost};
use aura_core::PrimitiveBuilder;
/// The single roster source (#160): one `"TypeId" => Type` line per zero-arg
@@ -67,6 +72,7 @@ std_vocabulary_roster! {
"CarryCost" => CarryCost,
"Const" => Const,
"ConstantCost" => ConstantCost,
"CostSum" => CostSum,
"CumSum" => CumSum,
"Delay" => Delay,
"Div" => Div,
@@ -75,6 +81,7 @@ std_vocabulary_roster! {
"FixedStop" => FixedStop,
"Gt" => Gt,
"Latch" => Latch,
"LinComb" => LinComb,
"LongOnly" => LongOnly,
"Max" => Max,
"Min" => Min,
@@ -85,6 +92,7 @@ std_vocabulary_roster! {
"RollingMin" => RollingMin,
"Scale" => Scale,
"Select" => Select,
"Session" => Session,
"SessionFrankfurt" => SessionFrankfurt,
"Sign" => Sign,
"Sizer" => Sizer,
@@ -103,8 +111,11 @@ mod tests {
/// label back, and only the rostered keys do. The builder's own `label()`
/// is the independent oracle: a typo'd roster key fails the lookup-label
/// agreement even though the match arms and the list share the same
/// (mistyped) literal; construction-arg nodes and sinks stay absent so the
/// loader fails cleanly rather than guessing.
/// (mistyped) literal; an unresolvable id and a sink stay absent so the
/// loader fails cleanly rather than guessing. `LinComb` — an arg-bearing
/// type, #271 — now resolves to a PENDING builder (its own `.label()`
/// still round-trips; it just is not yet constructible without
/// `try_args`).
#[test]
fn std_vocabulary_resolves_known_and_rejects_unknown() {
for &type_id in std_vocabulary_types() {
@@ -116,9 +127,9 @@ mod tests {
"resolver key must round-trip to the same type label"
);
}
// an unknown id, a construction-arg node, and a sink are all absent
// an unknown id and a sink are absent; LinComb is now resolved (#271)
assert!(std_vocabulary("nope").is_none());
assert!(std_vocabulary("LinComb").is_none());
assert!(std_vocabulary("LinComb").is_some());
assert!(std_vocabulary("Recorder").is_none());
}
@@ -129,14 +140,16 @@ mod tests {
/// act. The residual #160 drift — a new zero-arg node never rostered at
/// all — is not catchable here (no enumeration of zero-arg builders
/// exists); it fails safe (clean `UnknownNodeType` on load, merely absent
/// from `--vocabulary`).
/// from `--vocabulary`). #271: `Session`/`LinComb`/`CostSum` moved IN
/// (arg-bearing types now have zero-arg `builder()` factories); the count
/// pin moves 33 -> 36.
#[test]
fn std_vocabulary_types_lists_exactly_the_resolvable_keys() {
// known non-members stay out of the list
assert!(!std_vocabulary_types().contains(&"LinComb")); // construction-arg node
assert!(std_vocabulary_types().contains(&"LinComb")); // now arg-bearing-reachable (#271)
assert!(!std_vocabulary_types().contains(&"Recorder")); // sink
assert!(!std_vocabulary_types().contains(&"nope"));
// count guard: pins the roster at exactly 33 entries
assert_eq!(std_vocabulary_types().len(), 33);
// count guard: pins the roster at exactly 36 entries
assert_eq!(std_vocabulary_types().len(), 36);
}
}