From a8b1ba45c5c3f0e2db406066428c7990934e2915 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 24 Jul 2026 21:43:02 +0200 Subject: [PATCH] feat(aura-engine, aura-vocabulary, aura-cli): the args channel end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/aura-cli/Cargo.toml | 9 + crates/aura-cli/src/graph_construct.rs | 54 ++++- crates/aura-cli/src/main.rs | 48 +++++ crates/aura-cli/tests/graph_construct.rs | 77 ++++++- crates/aura-engine/src/blueprint_serde.rs | 234 ++++++++++++++++++++-- crates/aura-engine/src/construction.rs | 196 ++++++++++++------ crates/aura-engine/src/lib.rs | 7 +- crates/aura-registry/src/lib.rs | 12 +- crates/aura-vocabulary/src/lib.rs | 51 +++-- 9 files changed, 580 insertions(+), 108 deletions(-) diff --git a/crates/aura-cli/Cargo.toml b/crates/aura-cli/Cargo.toml index aae3f6c..474e991 100644 --- a/crates/aura-cli/Cargo.toml +++ b/crates/aura-cli/Cargo.toml @@ -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" } diff --git a/crates/aura-cli/src/graph_construct.rs b/crates/aura-cli/src/graph_construct.rs index 64cbd68..3c2f849 100644 --- a/crates/aura-cli/src/graph_construct.rs +++ b/crates/aura-cli/src/graph_construct.rs @@ -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 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, + // 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, #[serde(default)] bind: BTreeMap, }, @@ -151,9 +161,12 @@ impl From 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 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)), } } diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 0c8e542..b858bc3 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -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 — diff --git a/crates/aura-cli/tests/graph_construct.rs b/crates/aura-cli/tests/graph_construct.rs index be6eb9a..b11f0f9 100644 --- a/crates/aura-cli/tests/graph_construct.rs +++ b/crates/aura-cli/tests/graph_construct.rs @@ -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#"[ diff --git a/crates/aura-engine/src/blueprint_serde.rs b/crates/aura-engine/src/blueprint_serde.rs index 329b65a..3e5d3f8 100644 --- a/crates/aura-engine/src/blueprint_serde.rs +++ b/crates/aura-engine/src/blueprint_serde.rs @@ -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, + /// 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, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub bound: Vec, } +/// 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 = 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 { @@ -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, ) -> Result { 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:?}" + ); + } } diff --git a/crates/aura-engine/src/construction.rs b/crates/aura-engine/src/construction.rs index cdf5e5f..3190baa 100644 --- a/crates/aura-engine/src/construction.rs +++ b/crates/aura-engine/src/construction.rs @@ -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, 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, + args: Vec<(String, String)>, + bind: Vec<(String, Scalar)>, + }, /// Fan a role into one or more `"node.slot"` consumers. Feed { role: String, into: Vec }, /// 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, + 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() }, diff --git a/crates/aura-engine/src/lib.rs b/crates/aura-engine/src/lib.rs index 0948df6..820977f 100644 --- a/crates/aura-engine/src/lib.rs +++ b/crates/aura-engine/src/lib.rs @@ -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)] diff --git a/crates/aura-registry/src/lib.rs b/crates/aura-registry/src/lib.rs index 784e09e..a15b034 100644 --- a/crates/aura-registry/src/lib.rs +++ b/crates/aura-registry/src/lib.rs @@ -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()], diff --git a/crates/aura-vocabulary/src/lib.rs b/crates/aura-vocabulary/src/lib.rs index eb3486b..09fe48c 100644 --- a/crates/aura-vocabulary/src/lib.rs +++ b/crates/aura-vocabulary/src/lib.rs @@ -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); } }