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:
@@ -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" }
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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#"[
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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()],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user